plain.observer 0.1.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/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
@@ -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
@@ -1,70 +1,122 @@
1
- <div class="flex items-center justify-between mb-3">
2
- <div>
3
- <h2 class="text-lg font-semibold">{{ trace.root_span_name }}</h2>
4
- <div class="text-xs text-stone-500 mt-1">
5
- {{ trace.start_time|localtime|strftime("%b %-d, %-I:%M %p") }} • {{ "%.1f"|format(trace.duration_ms() or 0) }}ms
1
+ <header class="flex items-start justify-between mb-2">
2
+ <h2 class="text-lg font-semibold text-white">{{ trace.root_span_name }}</h2>
3
+ <div class="flex items-start space-x-2">
4
+ {% if not is_share_view|default(False) %}
5
+ {% htmxfragment "share" %}
6
+ {% if trace.share_id %}
7
+ <div class="flex items-center h-8 space-x-2 bg-emerald-900/20 border border-emerald-700/50 rounded-md px-2">
8
+ <svg class="w-4 h-4" fill="currentColor" class="text-emerald-500 flex-shrink-0" viewBox="0 0 16 16">
9
+ <path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
10
+ <path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
11
+ </svg>
12
+ <span class="text-xs text-emerald-400 font-mono block max-w-xs overflow-hidden text-ellipsis whitespace-nowrap mr-2" style="direction: rtl; text-align: left;" id="share-url-{{ trace.id }}">{{ request.get_host() }}{{ url('observer:trace_shared', trace.share_id) }}</span>
13
+ <button
14
+ onclick="copyShareUrl(this, '{{ trace.id }}')"
15
+ data-share-url="{{ request.build_absolute_uri(url('observer:trace_shared', trace.share_id)) }}"
16
+ class="h-6 w-6 flex items-center justify-center rounded-sm bg-emerald-700 text-emerald-400 hover:bg-emerald-600 cursor-pointer transition-colors flex-shrink-0"
17
+ title="Copy shareable URL">
18
+ <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 16 16">
19
+ <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
20
+ <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
21
+ </svg>
22
+ </button>
23
+ <button
24
+ hx-delete="{{ trace.get_absolute_url() }}"
25
+ plain-hx-action="share"
26
+ class="h-6 w-6 flex items-center justify-center rounded-sm bg-white/20 text-white/70 hover:bg-red-600 hover:text-white cursor-pointer transition-colors flex-shrink-0"
27
+ title="Remove shareable URL">
28
+ <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 16 16">
29
+ <path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
30
+ </svg>
31
+ </button>
6
32
  </div>
7
- </div>
8
- <div class="flex items-center space-x-2">
33
+ {% else %}
9
34
  <button
10
- onclick="copyTraceAsJson('{{ trace.id }}', this)"
11
- class="p-1.5 rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer transition-colors"
12
- title="Copy trace data as JSON">
13
- <svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
14
- <path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
15
- <path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
35
+ hx-post="{{ trace.get_absolute_url() }}"
36
+ plain-hx-action="share"
37
+ class="h-8 w-8 flex items-center justify-center rounded-sm bg-white/10 text-white/70 hover:bg-white/20 cursor-pointer transition-colors border border-transparent"
38
+ title="Generate shareable URL">
39
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16">
40
+ <path d="M11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.5 2.5 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5"/>
16
41
  </svg>
17
42
  </button>
18
- {% if show_delete_button|default(true) %}
43
+ {% endif %}
44
+ {% endhtmxfragment %}
45
+ {% endif %}
46
+ <a
47
+ href="{{ trace.get_absolute_url() }}?format=json"
48
+ target="_blank"
49
+ class="h-8 w-8 flex items-center justify-center rounded-sm bg-white/10 text-white/70 hover:bg-white/20 cursor-pointer transition-colors"
50
+ title="View as JSON">
51
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16">
52
+ <path fill-rule="evenodd" d="M14 4.5V11h-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM4.151 15.29a1.2 1.2 0 0 1-.111-.449h.764a.58.58 0 0 0 .255.384q.105.073.25.114.142.041.319.041.245 0 .413-.07a.56.56 0 0 0 .255-.193.5.5 0 0 0 .084-.29.39.39 0 0 0-.152-.326q-.152-.12-.463-.193l-.618-.143a1.7 1.7 0 0 1-.539-.214 1 1 0 0 1-.352-.367 1.1 1.1 0 0 1-.123-.524q0-.366.19-.639.192-.272.528-.422.337-.15.777-.149.456 0 .779.152.326.153.5.41.18.255.2.566h-.75a.56.56 0 0 0-.12-.258.6.6 0 0 0-.246-.181.9.9 0 0 0-.37-.068q-.324 0-.512.152a.47.47 0 0 0-.185.384q0 .18.144.3a1 1 0 0 0 .404.175l.621.143q.326.075.566.211a1 1 0 0 1 .375.358q.135.222.135.56 0 .37-.188.656a1.2 1.2 0 0 1-.539.439q-.351.158-.858.158-.381 0-.665-.09a1.4 1.4 0 0 1-.478-.252 1.1 1.1 0 0 1-.29-.375m-3.104-.033a1.3 1.3 0 0 1-.082-.466h.764a.6.6 0 0 0 .074.27.5.5 0 0 0 .454.246q.285 0 .422-.164.137-.165.137-.466v-2.745h.791v2.725q0 .66-.357 1.005-.355.345-.985.345a1.6 1.6 0 0 1-.568-.094 1.15 1.15 0 0 1-.407-.266 1.1 1.1 0 0 1-.243-.39m9.091-1.585v.522q0 .384-.117.641a.86.86 0 0 1-.322.387.9.9 0 0 1-.47.126.9.9 0 0 1-.47-.126.87.87 0 0 1-.32-.387 1.55 1.55 0 0 1-.117-.641v-.522q0-.386.117-.641a.87.87 0 0 1 .32-.387.87.87 0 0 1 .47-.129q.265 0 .47.129a.86.86 0 0 1 .322.387q.117.255.117.641m.803.519v-.513q0-.565-.205-.973a1.46 1.46 0 0 0-.59-.63q-.38-.22-.916-.22-.534 0-.92.22a1.44 1.44 0 0 0-.589.628q-.205.407-.205.975v.513q0 .562.205.973.205.407.589.626.386.217.92.217.536 0 .917-.217.384-.22.589-.626.204-.41.205-.973m1.29-.935v2.675h-.746v-3.999h.662l1.752 2.66h.032v-2.66h.75v4h-.656l-1.761-2.676z"/>
53
+ </svg>
54
+ </a>
55
+ {% if not is_share_view|default(False) %}
19
56
  <button
20
- hx-delete="?trace_id={{ trace.id }}"
21
- plain-hx-action="trace"
22
- hx-swap="morph:innerHTML"
23
- hx-target="#main-content"
57
+ hx-delete="{{ trace.get_absolute_url() }}"
58
+ hx-swap="none"
24
59
  hx-confirm="Delete this trace?"
25
- class="p-1.5 rounded-sm bg-stone-700 text-stone-300 hover:bg-red-600 hover:text-white cursor-pointer transition-colors"
60
+ class="h-8 w-8 flex items-center justify-center rounded-sm bg-white/10 text-white/70 hover:bg-red-600 hover:text-white cursor-pointer transition-colors"
26
61
  title="Delete this trace">
27
- <svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
62
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16">
28
63
  <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6Z"/>
29
64
  <path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1ZM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118ZM2.5 3h11V2h-11v1Z"/>
30
65
  </svg>
31
66
  </button>
32
67
  {% endif %}
33
68
  </div>
34
- </div>
35
- <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-stone-500 mb-4">
36
- <div>
37
- <span class="text-stone-400">Trace ID:</span> <span class="font-mono">{{ trace.trace_id }}</span>
38
- </div>
39
- {% if trace.request_id %}
69
+ </header>
70
+ <div class="space-y-2">
71
+ {% if trace.summary %}
40
72
  <div>
41
- <span class="text-stone-400">Request:</span> <span class="font-mono">{{ trace.request_id }}</span>
73
+ <span class="text-xs inline-flex items-center px-2 py-0.5 bg-white/10 text-white/80 rounded-md border border-white/10">
74
+ {{ trace.summary }}
75
+ </span>
42
76
  </div>
43
77
  {% endif %}
44
- {% if trace.user_id %}
45
- <div>
46
- <span class="text-stone-400">User:</span> {{ trace.user_id }}
78
+ <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/60">
79
+ <div>
80
+ <span class="text-white/50">Trace ID:</span> {{ trace.trace_id }}
81
+ </div>
82
+ <div>
83
+ <span class="text-white/50">Started:</span> {{ trace.start_time|localtime|strftime("%b %-d, %-I:%M %p") }}
84
+ </div>
85
+ <div>
86
+ <span class="text-white/50">Duration:</span> {{ "%.1f"|format(trace.duration_ms() or 0) }}ms
87
+ </div>
47
88
  </div>
48
- {% endif %}
49
- {% if trace.session_id %}
50
- <div>
51
- <span class="text-stone-400">Session:</span> {{ trace.session_id }}
89
+ <div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/60">
90
+ {% if trace.request_id %}
91
+ <div>
92
+ <span class="text-white/50">Request:</span> {{ trace.request_id }}
93
+ </div>
94
+ {% endif %}
95
+ {% if trace.user_id %}
96
+ <div>
97
+ <span class="text-white/50">User:</span> {{ trace.user_id }}
98
+ </div>
99
+ {% endif %}
100
+ {% if trace.session_id %}
101
+ <div>
102
+ <span class="text-white/50">Session:</span> {{ trace.session_id }}
103
+ </div>
104
+ {% endif %}
52
105
  </div>
53
- {% endif %}
54
106
  </div>
55
107
 
56
108
  <!-- Spans waterfall visualization -->
57
109
  <div class="mt-4 space-y-1 text-xs">
58
- {% for span in trace.get_annotated_spans() %}
110
+ {% for span in trace.spans.all().annotate_spans() %}
59
111
 
60
112
  <!-- Calculate relative positioning for waterfall -->
61
113
  {% set span_start_offset = ((span.start_time - trace.start_time).total_seconds() * 1000) %}
62
114
  {% set start_percent = (span_start_offset / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
63
115
  {% set width_percent = (span.duration_ms() / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
64
116
 
65
- <div style="padding-left: {{ span.level * 1 }}rem;" class="border-l border-stone-700">
66
- <details id="{{ trace.id }}-span-{{ loop.index }}" class="rounded bg-white/5 hover:bg-white/10 transition-colors min-w-[600px] ml-px">
67
- <summary class="cursor-pointer p-2 list-none [&::-webkit-details-marker]:hidden">
117
+ <div style="padding-left: {{ span.level * 1 }}rem;" class="border-l border-white/20">
118
+ <details id="{{ trace.id }}-span-{{ loop.index }}" class="rounded bg-white/5 min-w-[600px] ml-px">
119
+ <summary class="cursor-pointer hover:bg-white/10 transition-colors p-2 list-none [&::-webkit-details-marker]:hidden">
68
120
  <div class="flex items-center">
69
121
  <div class="w-4 h-4 mr-2 flex items-center justify-center">
70
122
  <svg class="w-3 h-3 transform transition-transform details-open:rotate-90" fill="currentColor" viewBox="0 0 20 20">
@@ -73,10 +125,10 @@
73
125
  </div>
74
126
 
75
127
  <div class="w-80 flex items-center space-x-2">
76
- <div class="text-stone-400 whitespace-nowrap">
128
+ <div class="text-white/40 whitespace-nowrap">
77
129
  {{ span.start_time|localtime|strftime("%-I:%M:%S %p") }}
78
130
  </div>
79
- <div class="flex-grow whitespace-nowrap truncate">{{ span.name }}</div>
131
+ <div class="flex-grow whitespace-nowrap truncate text-white/90">{{ span.name }}</div>
80
132
 
81
133
  {% if span.annotations %}
82
134
  <div class="flex items-center space-x-1 flex-shrink-0">
@@ -98,7 +150,7 @@
98
150
  </div>
99
151
 
100
152
  <div class="flex-1 px-4 min-w-[300px]">
101
- <div class="relative h-6 bg-stone-800/50 rounded-sm">
153
+ <div class="relative h-6 bg-white/2 rounded-sm">
102
154
  <div
103
155
  class="absolute top-1 bottom-1 rounded-sm transition-opacity hover:opacity-80
104
156
  data-[kind='SERVER']:bg-blue-500
@@ -106,7 +158,7 @@
106
158
  data-[kind='CONSUMER']:bg-amber-500
107
159
  data-[kind='PRODUCER']:bg-purple-500
108
160
  data-[kind='INTERNAL']:bg-gray-500
109
- bg-stone-600"
161
+ bg-white/30"
110
162
  data-kind="{{ span.kind }}"
111
163
  style="left: {{ start_percent }}%; width: {{ width_percent }}%;"
112
164
  title="{{ span.name }} - {{ span.duration_ms() }}ms">
@@ -120,12 +172,12 @@
120
172
  </div>
121
173
  </div>
122
174
  </summary>
123
- <div class="p-4 pt-2 bg-stone-900/50 border-t border-stone-700">
175
+ <div class="p-4 pt-2 bg-white/3 border-t border-white/20">
124
176
  {% if span.sql_query %}
125
- <div class="mb-6 bg-stone-800 rounded-lg border border-stone-600 overflow-hidden
177
+ <div class="mb-6 bg-white/3 rounded-lg border border-white/20 overflow-hidden
126
178
  {% if span.annotations %}ring-2 ring-amber-500/50{% endif %}">
127
- <div class="bg-stone-700 px-4 py-2 border-b border-stone-600 flex items-center justify-between">
128
- <h4 class="text-sm font-semibold text-emerald-500 flex items-center">
179
+ <div class="bg-white/10 px-4 py-2 border-b border-white/20 flex items-center justify-between">
180
+ <h4 class="text-sm font-semibold text-emerald-400 flex items-center">
129
181
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-database-fill w-4 h-4 mr-2" viewBox="0 0 16 16">
130
182
  <path d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223"/>
131
183
  <path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
@@ -152,16 +204,16 @@
152
204
  {% endif %}
153
205
  </div>
154
206
  <div class="p-4">
155
- <pre class="text-xs text-stone-200 font-mono whitespace-pre-wrap overflow-x-auto"><code>{{ span.get_formatted_sql() }}</code></pre>
207
+ <pre class="text-xs text-white/80 font-mono whitespace-pre-wrap overflow-x-auto"><code>{{ span.get_formatted_sql() }}</code></pre>
156
208
 
157
209
  {% if span.sql_query_params %}
158
- <div class="mt-4 pt-4 border-t border-stone-600">
159
- <h5 class="text-xs font-semibold text-stone-400 mb-2">Query Parameters</h5>
210
+ <div class="mt-4 pt-4 border-t border-white/20">
211
+ <h5 class="text-xs font-semibold text-white/40 mb-2">Query Parameters</h5>
160
212
  <div class="space-y-1">
161
213
  {% for param_key, param_value in span.sql_query_params.items() %}
162
214
  <div class="flex text-xs">
163
- <span class="text-stone-500 min-w-0 flex-shrink-0 pr-2 font-mono">{{ param_key }}:</span>
164
- <span class="text-stone-300 break-words font-mono">{{ param_value }}</span>
215
+ <span class="text-white/50 min-w-0 flex-shrink-0 pr-2 font-mono">{{ param_key }}:</span>
216
+ <span class="text-white/70 break-words font-mono">{{ param_value }}</span>
165
217
  </div>
166
218
  {% endfor %}
167
219
  </div>
@@ -189,18 +241,18 @@
189
241
 
190
242
  <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
191
243
  <div>
192
- <h4 class="text-sm font-semibold text-stone-300 mb-3">Basic Information</h4>
244
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Basic Information</h4>
193
245
  <div class="space-y-2 text-xs">
194
246
  <div class="flex">
195
- <span class="text-stone-400 w-20">ID:</span>
196
- <span class="text-stone-200 font-mono">{{ span.span_id }}</span>
247
+ <span class="text-white/40 w-20">ID:</span>
248
+ <span class="text-white/80 font-mono">{{ span.span_id }}</span>
197
249
  </div>
198
250
  <div class="flex">
199
- <span class="text-stone-400 w-20">Name:</span>
200
- <span class="text-stone-200">{{ span.name }}</span>
251
+ <span class="text-white/40 w-20">Name:</span>
252
+ <span class="text-white/80">{{ span.name }}</span>
201
253
  </div>
202
254
  <div class="flex">
203
- <span class="text-stone-400 w-20">Kind:</span>
255
+ <span class="text-white/40 w-20">Kind:</span>
204
256
  <span class="px-2 py-0.5 rounded text-xs font-medium
205
257
  data-[kind='SERVER']:bg-blue-500/20 data-[kind='SERVER']:text-blue-300
206
258
  data-[kind='CLIENT']:bg-emerald-500/20 data-[kind='CLIENT']:text-emerald-300
@@ -213,32 +265,32 @@
213
265
  </span>
214
266
  </div>
215
267
  <div class="flex">
216
- <span class="text-stone-400 w-20">Duration:</span>
217
- <span class="text-stone-200">{{ "%.2f"|format(span.duration_ms() or 0) }}ms</span>
268
+ <span class="text-white/40 w-20">Duration:</span>
269
+ <span class="text-white/80">{{ "%.2f"|format(span.duration_ms() or 0) }}ms</span>
218
270
  </div>
219
271
  {% if span.parent_id %}
220
272
  <div class="flex">
221
- <span class="text-stone-400 w-20">Parent:</span>
222
- <span class="text-stone-200 font-mono text-xs">{{ span.parent_id }}</span>
273
+ <span class="text-white/40 w-20">Parent:</span>
274
+ <span class="text-white/80 font-mono text-xs">{{ span.parent_id }}</span>
223
275
  </div>
224
276
  {% endif %}
225
277
  </div>
226
278
  </div>
227
279
 
228
280
  <div>
229
- <h4 class="text-sm font-semibold text-stone-300 mb-3">Timing</h4>
281
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Timing</h4>
230
282
  <div class="space-y-2 text-xs">
231
283
  <div class="flex">
232
- <span class="text-stone-400 w-20">Started:</span>
233
- <span class="text-stone-200">{{ span.start_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
284
+ <span class="text-white/40 w-20">Started:</span>
285
+ <span class="text-white/80">{{ span.start_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
234
286
  </div>
235
287
  <div class="flex">
236
- <span class="text-stone-400 w-20">Ended:</span>
237
- <span class="text-stone-200">{{ span.end_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
288
+ <span class="text-white/40 w-20">Ended:</span>
289
+ <span class="text-white/80">{{ span.end_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
238
290
  </div>
239
291
  {% if span.status and span.status != '' and span.status != 'UNSET' %}
240
292
  <div class="flex">
241
- <span class="text-stone-400 w-20">Status:</span>
293
+ <span class="text-white/40 w-20">Status:</span>
242
294
  <span class="px-2 py-0.5 rounded text-xs font-medium
243
295
  data-[status='ERROR']:bg-red-500/20 data-[status='ERROR']:text-red-300
244
296
  data-[status='OK']:bg-green-500/20 data-[status='OK']:text-green-300
@@ -254,13 +306,13 @@
254
306
 
255
307
  {% if span.attributes %}
256
308
  <div class="mt-6">
257
- <h4 class="text-sm font-semibold text-stone-300 mb-3">Attributes</h4>
258
- <div class="bg-stone-800/50 rounded p-3 max-h-48 overflow-y-auto">
309
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Attributes</h4>
310
+ <div class="bg-white/3 rounded p-3 max-h-48 overflow-y-auto">
259
311
  <div class="space-y-1 text-xs">
260
312
  {% for key, value in span.attributes.items() %}
261
313
  <div class="flex">
262
- <span class="text-stone-400 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
263
- <span class="text-stone-200 break-words">{{ value }}</span>
314
+ <span class="text-white/40 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
315
+ <span class="text-white/80 break-words">{{ value }}</span>
264
316
  </div>
265
317
  {% endfor %}
266
318
  </div>
@@ -270,14 +322,14 @@
270
322
 
271
323
  {% if span.events %}
272
324
  <div class="mt-6">
273
- <h4 class="text-sm font-semibold text-stone-300 mb-3">Events ({{ span.events|length }})</h4>
274
- <div class="bg-stone-800/50 rounded p-3 max-h-48 overflow-y-auto">
325
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Events ({{ span.events|length }})</h4>
326
+ <div class="bg-white/3 rounded p-3 max-h-48 overflow-y-auto">
275
327
  <div class="space-y-3 text-xs">
276
328
  {% for event in span.events %}
277
- <div class="border-l-2 border-stone-600 pl-3">
329
+ <div class="border-l-2 border-white/20 pl-3">
278
330
  <div class="flex items-center justify-between mb-1">
279
- <div class="text-stone-200 font-medium">{{ event.name }}</div>
280
- <div class="text-stone-400 text-xs">
331
+ <div class="text-white/80 font-medium">{{ event.name }}</div>
332
+ <div class="text-white/40 text-xs">
281
333
  {% set formatted_time = span.format_event_timestamp(event.timestamp) %}
282
334
  {% if formatted_time.__class__.__name__ == 'datetime' %}
283
335
  {{ formatted_time|localtime|strftime("%-I:%M:%S.%f %p") }}
@@ -290,8 +342,8 @@
290
342
  <div class="space-y-1">
291
343
  {% for key, value in event.attributes.items() %}
292
344
  <div class="flex">
293
- <span class="text-stone-400 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
294
- <pre class="text-stone-200 whitespace-pre-wrap break-words font-mono text-xs">{{ value }}</pre>
345
+ <span class="text-white/40 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
346
+ <pre class="text-white/80 whitespace-pre-wrap break-words font-mono text-xs">{{ value }}</pre>
295
347
  </div>
296
348
  {% endfor %}
297
349
  </div>
@@ -305,13 +357,13 @@
305
357
 
306
358
  {% if span.links %}
307
359
  <div class="mt-6">
308
- <h4 class="text-sm font-semibold text-stone-300 mb-3">Links ({{ span.links|length }})</h4>
360
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Links ({{ span.links|length }})</h4>
309
361
  <div class="bg-stone-800/50 rounded p-3">
310
362
  <div class="space-y-2 text-xs">
311
363
  {% for link in span.links %}
312
- <div class="border-l-2 border-stone-600 pl-2">
313
- <div class="text-stone-200 font-mono">{{ link.context.trace_id }}</div>
314
- <div class="text-stone-400 font-mono">{{ link.context.span_id }}</div>
364
+ <div class="border-l-2 border-white/20 pl-2">
365
+ <div class="text-white/80 font-mono">{{ link.context.trace_id }}</div>
366
+ <div class="text-white/40 font-mono">{{ link.context.span_id }}</div>
315
367
  </div>
316
368
  {% endfor %}
317
369
  </div>
@@ -334,31 +386,36 @@
334
386
  </style>
335
387
 
336
388
  <script>
337
- async function copyTraceAsJson(traceId, button) {
389
+ async function copyShareUrl(button, traceId) {
338
390
  try {
339
- const response = await fetch(`?trace_id=${traceId}&format=json`);
340
- const data = await response.json();
341
-
342
- // Pretty print the JSON
343
- const jsonString = JSON.stringify(data, null, 2);
391
+ const shareUrl = button.getAttribute('data-share-url');
344
392
 
345
393
  // Copy to clipboard
346
- await navigator.clipboard.writeText(jsonString);
394
+ await navigator.clipboard.writeText(shareUrl);
347
395
 
348
- // Show success feedback
349
- const originalTitle = button.title;
350
- button.title = 'Copied!';
351
- button.classList.remove('bg-stone-700', 'hover:bg-stone-600');
396
+ // Show success feedback on button
397
+ const originalHTML = button.innerHTML;
398
+ button.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
399
+ button.classList.remove('bg-emerald-700', 'hover:bg-emerald-600');
352
400
  button.classList.add('bg-green-600', 'hover:bg-green-700');
353
401
 
402
+ // Also flash the URL text
403
+ const urlSpan = document.getElementById(`share-url-${traceId}`);
404
+ if (urlSpan) {
405
+ urlSpan.classList.add('text-green-400', 'font-bold');
406
+ setTimeout(() => {
407
+ urlSpan.classList.remove('text-green-400', 'font-bold');
408
+ }, 2000);
409
+ }
410
+
354
411
  setTimeout(() => {
355
- button.title = originalTitle;
412
+ button.innerHTML = originalHTML;
356
413
  button.classList.remove('bg-green-600', 'hover:bg-green-700');
357
- button.classList.add('bg-stone-700', 'hover:bg-stone-600');
414
+ button.classList.add('bg-emerald-700', 'hover:bg-emerald-600');
358
415
  }, 2000);
359
416
  } catch (error) {
360
- console.error('Failed to copy trace data:', error);
361
- alert('Failed to copy trace data. See console for details.');
417
+ console.error('Failed to copy share URL:', error);
418
+ alert('Failed to copy share URL. See console for details.');
362
419
  }
363
420
  }
364
421
  </script>
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Trace {{ trace.trace_id }} - Observer</title>
7
+ {% tailwind_css %}
8
+ {% htmx_js %}
9
+ </head>
10
+ <body class="bg-stone-950 text-stone-300 min-h-screen">
11
+ <div class="container mx-auto p-6 max-w-6xl">
12
+ <div class="mb-4">
13
+ <a href="{{ url('observer:traces') }}" class="text-stone-400 hover:text-stone-200 text-sm flex items-center gap-1">
14
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
15
+ <path fill-rule="evenodd" d="M12 8a.5.5 0 0 1-.5.5H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5a.5.5 0 0 1 .5.5z"/>
16
+ </svg>
17
+ Back to traces
18
+ </a>
19
+ </div>
20
+
21
+ {% include "observer/trace.html" %}
22
+ </div>
23
+ </body>
24
+ </html>