plain.observer 0.1.0__tar.gz → 0.2.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.1.0 → plain_observer-0.2.0}/PKG-INFO +1 -1
- plain_observer-0.2.0/plain/observer/CHANGELOG.md +30 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/admin.py +0 -8
- plain_observer-0.2.0/plain/observer/cli.py +576 -0
- plain_observer-0.2.0/plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +58 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/models.py +56 -27
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/otel.py +7 -0
- plain_observer-0.1.0/plain/observer/templates/observer/_trace_detail.html → plain_observer-0.2.0/plain/observer/templates/observer/trace.html +155 -98
- plain_observer-0.2.0/plain/observer/templates/observer/trace_detail.html +24 -0
- plain_observer-0.2.0/plain/observer/templates/observer/trace_share.html +19 -0
- plain_observer-0.2.0/plain/observer/templates/observer/traces.html +314 -0
- plain_observer-0.2.0/plain/observer/templates/toolbar/observer_button.html +29 -0
- plain_observer-0.2.0/plain/observer/urls.py +12 -0
- plain_observer-0.2.0/plain/observer/views.py +139 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/pyproject.toml +1 -1
- plain_observer-0.1.0/plain/observer/CHANGELOG.md +0 -15
- plain_observer-0.1.0/plain/observer/cli.py +0 -23
- plain_observer-0.1.0/plain/observer/templates/admin/observer/trace_detail.html +0 -10
- plain_observer-0.1.0/plain/observer/templates/observer/traces.html +0 -288
- plain_observer-0.1.0/plain/observer/templates/toolbar/observer_button.html +0 -45
- plain_observer-0.1.0/plain/observer/urls.py +0 -10
- plain_observer-0.1.0/plain/observer/views.py +0 -105
- {plain_observer-0.1.0 → plain_observer-0.2.0}/.gitignore +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/LICENSE +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/README.md +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/README.md +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/__init__.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/config.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/core.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/default_settings.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/migrations/0001_initial.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/migrations/__init__.py +0 -0
- {plain_observer-0.1.0 → plain_observer-0.2.0}/plain/observer/templates/toolbar/observer.html +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# plain-observer changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/dropseed/plain/releases/plain-observer@0.2.0) (2025-07-21)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Added comprehensive CLI commands for trace management including `plain observer traces`, `plain observer trace <id>`, and `plain observer spans` ([90f916b](https://github.com/dropseed/plain/commit/90f916b676))
|
|
8
|
+
- Added trace sharing functionality allowing traces to be shared via public URLs ([90f916b](https://github.com/dropseed/plain/commit/90f916b676))
|
|
9
|
+
- Added `plain observer diagnose` command with JSON and URL output options for troubleshooting ([71936e88a5](https://github.com/dropseed/plain/commit/71936e88a5))
|
|
10
|
+
- Improved trace detail UI with better formatting and navigation ([90f916b](https://github.com/dropseed/plain/commit/90f916b676))
|
|
11
|
+
- Removed the custom trace detail UI from the admin interface, now uses standard admin detail view ([0c277fc](https://github.com/dropseed/plain/commit/0c277fc076))
|
|
12
|
+
- Enhanced raw agent prompt output styling ([684f208](https://github.com/dropseed/plain/commit/684f2087fc))
|
|
13
|
+
|
|
14
|
+
### Upgrade instructions
|
|
15
|
+
|
|
16
|
+
- No changes required
|
|
17
|
+
|
|
18
|
+
## [0.1.0](https://github.com/dropseed/plain/releases/plain-observer@0.1.0) (2025-07-19)
|
|
19
|
+
|
|
20
|
+
### What's changed
|
|
21
|
+
|
|
22
|
+
- Initial release of plain-observer package providing OpenTelemetry-based observability and monitoring for Plain applications ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0418))
|
|
23
|
+
- Added real-time trace monitoring with summary and persist modes via signed cookies ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0418))
|
|
24
|
+
- Added admin interface for viewing detailed trace information and spans ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0418))
|
|
25
|
+
- Added toolbar integration showing performance summaries for current requests ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0418))
|
|
26
|
+
- Observer can now combine with existing OpenTelemetry trace providers instead of replacing them ([7e55779](https://github.com/dropseed/plain/commit/7e55779548))
|
|
27
|
+
|
|
28
|
+
### Upgrade instructions
|
|
29
|
+
|
|
30
|
+
- No changes required
|
|
@@ -34,14 +34,6 @@ class TraceViewset(AdminViewset):
|
|
|
34
34
|
|
|
35
35
|
class DetailView(AdminModelDetailView):
|
|
36
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
37
|
|
|
46
38
|
|
|
47
39
|
@register_viewset
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shlex
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from plain.cli import register_cli
|
|
10
|
+
from plain.observer.models import Span, Trace
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register_cli("observer")
|
|
14
|
+
@click.group("observer")
|
|
15
|
+
def observer_cli():
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@observer_cli.command()
|
|
20
|
+
@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
|
|
21
|
+
def clear(force: bool):
|
|
22
|
+
"""Clear all observer data."""
|
|
23
|
+
query = Trace.objects.all()
|
|
24
|
+
trace_count = query.count()
|
|
25
|
+
|
|
26
|
+
if trace_count == 0:
|
|
27
|
+
click.echo("No traces to clear.")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
if not force:
|
|
31
|
+
confirm_msg = f"Are you sure you want to clear {trace_count} trace(s)? This will delete all observer data."
|
|
32
|
+
click.confirm(confirm_msg, abort=True)
|
|
33
|
+
|
|
34
|
+
deleted_count, _ = query.delete()
|
|
35
|
+
click.secho(f"✓ Cleared {deleted_count} traces and spans", fg="green")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@observer_cli.command("traces")
|
|
39
|
+
@click.option("--limit", default=20, help="Number of traces to show (default: 20)")
|
|
40
|
+
@click.option("--user-id", help="Filter by user ID")
|
|
41
|
+
@click.option("--request-id", help="Filter by request ID")
|
|
42
|
+
@click.option("--session-id", help="Filter by session ID")
|
|
43
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
44
|
+
def trace_list(limit, user_id, request_id, session_id, output_json):
|
|
45
|
+
"""List recent traces."""
|
|
46
|
+
# Build query
|
|
47
|
+
query = Trace.objects.all()
|
|
48
|
+
|
|
49
|
+
if user_id:
|
|
50
|
+
query = query.filter(user_id=user_id)
|
|
51
|
+
if request_id:
|
|
52
|
+
query = query.filter(request_id=request_id)
|
|
53
|
+
if session_id:
|
|
54
|
+
query = query.filter(session_id=session_id)
|
|
55
|
+
|
|
56
|
+
# Limit results
|
|
57
|
+
traces = list(query[:limit])
|
|
58
|
+
|
|
59
|
+
if not traces:
|
|
60
|
+
click.echo("No traces found.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if output_json:
|
|
64
|
+
# Output as JSON array
|
|
65
|
+
output = []
|
|
66
|
+
for trace in traces:
|
|
67
|
+
output.append(
|
|
68
|
+
{
|
|
69
|
+
"trace_id": trace.trace_id,
|
|
70
|
+
"start_time": trace.start_time.isoformat(),
|
|
71
|
+
"end_time": trace.end_time.isoformat(),
|
|
72
|
+
"duration_ms": trace.duration_ms(),
|
|
73
|
+
"request_id": trace.request_id,
|
|
74
|
+
"user_id": trace.user_id,
|
|
75
|
+
"session_id": trace.session_id,
|
|
76
|
+
"root_span_name": trace.root_span_name,
|
|
77
|
+
"summary": trace.summary,
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
click.echo(json.dumps(output, indent=2))
|
|
81
|
+
else:
|
|
82
|
+
# Table-like output
|
|
83
|
+
click.secho(
|
|
84
|
+
f"Recent traces (showing {len(traces)} of {query.count()} total):",
|
|
85
|
+
fg="bright_blue",
|
|
86
|
+
bold=True,
|
|
87
|
+
)
|
|
88
|
+
click.echo()
|
|
89
|
+
|
|
90
|
+
# Headers
|
|
91
|
+
headers = [
|
|
92
|
+
"Trace ID",
|
|
93
|
+
"Start Time",
|
|
94
|
+
"Summary",
|
|
95
|
+
"Root Span",
|
|
96
|
+
"Request ID",
|
|
97
|
+
"User ID",
|
|
98
|
+
"Session ID",
|
|
99
|
+
]
|
|
100
|
+
col_widths = [41, 21, 31, 31, 22, 11, 22]
|
|
101
|
+
|
|
102
|
+
# Print headers
|
|
103
|
+
header_line = ""
|
|
104
|
+
for header, width in zip(headers, col_widths):
|
|
105
|
+
header_line += header.ljust(width)
|
|
106
|
+
click.secho(header_line, bold=True)
|
|
107
|
+
click.echo("-" * sum(col_widths))
|
|
108
|
+
|
|
109
|
+
# Print traces
|
|
110
|
+
for trace in traces:
|
|
111
|
+
row = [
|
|
112
|
+
trace.trace_id[:37] + "..."
|
|
113
|
+
if len(trace.trace_id) > 40
|
|
114
|
+
else trace.trace_id,
|
|
115
|
+
trace.start_time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
116
|
+
trace.summary[:27] + "..."
|
|
117
|
+
if len(trace.summary) > 30
|
|
118
|
+
else trace.summary,
|
|
119
|
+
trace.root_span_name[:27] + "..."
|
|
120
|
+
if len(trace.root_span_name) > 30
|
|
121
|
+
else trace.root_span_name,
|
|
122
|
+
trace.request_id[:18] + "..."
|
|
123
|
+
if len(trace.request_id) > 20
|
|
124
|
+
else trace.request_id,
|
|
125
|
+
trace.user_id[:10],
|
|
126
|
+
trace.session_id[:18] + "..."
|
|
127
|
+
if len(trace.session_id) > 20
|
|
128
|
+
else trace.session_id,
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
row_line = ""
|
|
132
|
+
for value, width in zip(row, col_widths):
|
|
133
|
+
row_line += str(value).ljust(width)
|
|
134
|
+
click.echo(row_line)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@observer_cli.command("trace")
|
|
138
|
+
@click.argument("trace_id")
|
|
139
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
140
|
+
def trace_detail(trace_id, output_json):
|
|
141
|
+
"""Display detailed information about a specific trace."""
|
|
142
|
+
try:
|
|
143
|
+
trace = Trace.objects.get(trace_id=trace_id)
|
|
144
|
+
except Trace.DoesNotExist:
|
|
145
|
+
click.secho(f"Error: Trace with ID '{trace_id}' not found", fg="red", err=True)
|
|
146
|
+
raise click.Abort()
|
|
147
|
+
|
|
148
|
+
if output_json:
|
|
149
|
+
click.echo(json.dumps(trace.as_dict(), indent=2))
|
|
150
|
+
else:
|
|
151
|
+
click.echo(format_trace_output(trace))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@observer_cli.command("spans")
|
|
155
|
+
@click.option("--trace-id", help="Filter by trace ID")
|
|
156
|
+
@click.option("--limit", default=50, help="Number of spans to show (default: 50)")
|
|
157
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
158
|
+
def span_list(trace_id, limit, output_json):
|
|
159
|
+
"""List recent spans."""
|
|
160
|
+
# Build query
|
|
161
|
+
query = Span.objects.all()
|
|
162
|
+
|
|
163
|
+
if trace_id:
|
|
164
|
+
query = query.filter(trace__trace_id=trace_id)
|
|
165
|
+
|
|
166
|
+
# Limit results
|
|
167
|
+
spans = list(query[:limit])
|
|
168
|
+
|
|
169
|
+
if not spans:
|
|
170
|
+
click.echo("No spans found.")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
if output_json:
|
|
174
|
+
# Output as JSON array
|
|
175
|
+
output = []
|
|
176
|
+
for span in spans:
|
|
177
|
+
output.append(
|
|
178
|
+
{
|
|
179
|
+
"span_id": span.span_id,
|
|
180
|
+
"trace_id": span.trace.trace_id,
|
|
181
|
+
"name": span.name,
|
|
182
|
+
"kind": span.kind,
|
|
183
|
+
"parent_id": span.parent_id,
|
|
184
|
+
"start_time": span.start_time.isoformat(),
|
|
185
|
+
"end_time": span.end_time.isoformat(),
|
|
186
|
+
"duration_ms": span.duration_ms(),
|
|
187
|
+
"status": span.status,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
click.echo(json.dumps(output, indent=2))
|
|
191
|
+
else:
|
|
192
|
+
# Table-like output
|
|
193
|
+
click.secho(
|
|
194
|
+
f"Recent spans (showing {len(spans)} of {query.count()} total):",
|
|
195
|
+
fg="bright_blue",
|
|
196
|
+
bold=True,
|
|
197
|
+
)
|
|
198
|
+
click.echo()
|
|
199
|
+
|
|
200
|
+
# Headers
|
|
201
|
+
headers = ["Span ID", "Trace ID", "Name", "Duration", "Kind", "Status"]
|
|
202
|
+
col_widths = [22, 22, 41, 12, 12, 16]
|
|
203
|
+
|
|
204
|
+
# Print headers
|
|
205
|
+
header_line = ""
|
|
206
|
+
for header, width in zip(headers, col_widths):
|
|
207
|
+
header_line += header.ljust(width)
|
|
208
|
+
click.secho(header_line, bold=True)
|
|
209
|
+
click.echo("-" * sum(col_widths))
|
|
210
|
+
|
|
211
|
+
# Print spans
|
|
212
|
+
for span in spans:
|
|
213
|
+
status_display = ""
|
|
214
|
+
if span.status:
|
|
215
|
+
if span.status in ["STATUS_CODE_OK", "OK"]:
|
|
216
|
+
status_display = "✓ OK"
|
|
217
|
+
elif span.status not in ["STATUS_CODE_UNSET", "UNSET"]:
|
|
218
|
+
status_display = f"✗ {span.status}"
|
|
219
|
+
|
|
220
|
+
row = [
|
|
221
|
+
span.span_id[:18] + "..." if len(span.span_id) > 20 else span.span_id,
|
|
222
|
+
span.trace.trace_id[:18] + "..."
|
|
223
|
+
if len(span.trace.trace_id) > 20
|
|
224
|
+
else span.trace.trace_id,
|
|
225
|
+
span.name[:37] + "..." if len(span.name) > 40 else span.name,
|
|
226
|
+
f"{span.duration_ms():.1f}ms",
|
|
227
|
+
span.kind[:10],
|
|
228
|
+
status_display[:15],
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# Build row with colored status
|
|
232
|
+
row_parts = []
|
|
233
|
+
for i, (value, width) in enumerate(zip(row, col_widths)):
|
|
234
|
+
if i == 5: # Status column
|
|
235
|
+
if span.status and span.status in ["STATUS_CODE_OK", "OK"]:
|
|
236
|
+
colored_value = click.style(str(value), fg="green")
|
|
237
|
+
# Need to account for the extra characters from coloring
|
|
238
|
+
padding = width - len(str(value))
|
|
239
|
+
row_parts.append(colored_value + " " * padding)
|
|
240
|
+
elif span.status and span.status not in [
|
|
241
|
+
"STATUS_CODE_UNSET",
|
|
242
|
+
"UNSET",
|
|
243
|
+
"",
|
|
244
|
+
]:
|
|
245
|
+
colored_value = click.style(str(value), fg="red")
|
|
246
|
+
# Need to account for the extra characters from coloring
|
|
247
|
+
padding = width - len(str(value))
|
|
248
|
+
row_parts.append(colored_value + " " * padding)
|
|
249
|
+
else:
|
|
250
|
+
row_parts.append(str(value).ljust(width))
|
|
251
|
+
else:
|
|
252
|
+
row_parts.append(str(value).ljust(width))
|
|
253
|
+
|
|
254
|
+
click.echo("".join(row_parts))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@observer_cli.command("span")
|
|
258
|
+
@click.argument("span_id")
|
|
259
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
260
|
+
def span_detail(span_id, output_json):
|
|
261
|
+
"""Display detailed information about a specific span."""
|
|
262
|
+
try:
|
|
263
|
+
span = Span.objects.select_related("trace").get(span_id=span_id)
|
|
264
|
+
except Span.DoesNotExist:
|
|
265
|
+
click.secho(f"Error: Span with ID '{span_id}' not found", fg="red", err=True)
|
|
266
|
+
raise click.Abort()
|
|
267
|
+
|
|
268
|
+
if output_json:
|
|
269
|
+
# Output as JSON
|
|
270
|
+
click.echo(json.dumps(span.span_data, indent=2))
|
|
271
|
+
else:
|
|
272
|
+
# Detailed output
|
|
273
|
+
label_width = 12
|
|
274
|
+
click.secho(
|
|
275
|
+
f"{'Span:':<{label_width}} {span.span_id}", fg="bright_blue", bold=True
|
|
276
|
+
)
|
|
277
|
+
click.echo(f"{'Trace:':<{label_width}} {span.trace.trace_id}")
|
|
278
|
+
click.echo(f"{'Name:':<{label_width}} {span.name}")
|
|
279
|
+
click.echo(f"{'Kind:':<{label_width}} {span.kind}")
|
|
280
|
+
click.echo(
|
|
281
|
+
f"{'Start:':<{label_width}} {span.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
|
|
282
|
+
)
|
|
283
|
+
click.echo(
|
|
284
|
+
f"{'End:':<{label_width}} {span.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
|
|
285
|
+
)
|
|
286
|
+
click.echo(f"{'Duration:':<{label_width}} {span.duration_ms():.2f}ms")
|
|
287
|
+
|
|
288
|
+
if span.parent_id:
|
|
289
|
+
click.echo(f"{'Parent ID:':<{label_width}} {span.parent_id}")
|
|
290
|
+
|
|
291
|
+
if span.status:
|
|
292
|
+
status_color = "green" if span.status in ["STATUS_CODE_OK", "OK"] else "red"
|
|
293
|
+
click.echo(f"{'Status:':<{label_width}} ", nl=False)
|
|
294
|
+
click.secho(span.status, fg=status_color)
|
|
295
|
+
|
|
296
|
+
# Show attributes
|
|
297
|
+
if span.attributes:
|
|
298
|
+
click.echo()
|
|
299
|
+
click.secho("Attributes:", fg="bright_blue", bold=True)
|
|
300
|
+
for key, value in span.attributes.items():
|
|
301
|
+
# Format value based on type
|
|
302
|
+
if isinstance(value, str) and len(value) > 100:
|
|
303
|
+
value = value[:97] + "..."
|
|
304
|
+
click.echo(f" {key}: {value}")
|
|
305
|
+
|
|
306
|
+
# Show SQL query if present
|
|
307
|
+
if span.sql_query:
|
|
308
|
+
click.echo()
|
|
309
|
+
click.secho("SQL Query:", fg="bright_blue", bold=True)
|
|
310
|
+
formatted_sql = span.get_formatted_sql()
|
|
311
|
+
if formatted_sql:
|
|
312
|
+
for line in formatted_sql.split("\n"):
|
|
313
|
+
click.echo(f" {line}")
|
|
314
|
+
|
|
315
|
+
# Show query parameters
|
|
316
|
+
if span.sql_query_params:
|
|
317
|
+
click.echo()
|
|
318
|
+
click.secho("Query Parameters:", fg="bright_blue", bold=True)
|
|
319
|
+
for param, value in span.sql_query_params.items():
|
|
320
|
+
click.echo(f" {param}: {value}")
|
|
321
|
+
|
|
322
|
+
# Show events
|
|
323
|
+
if span.events:
|
|
324
|
+
click.echo()
|
|
325
|
+
click.secho("Events:", fg="bright_blue", bold=True)
|
|
326
|
+
for event in span.events:
|
|
327
|
+
timestamp = span.format_event_timestamp(event.get("timestamp", ""))
|
|
328
|
+
click.echo(f" {event.get('name', 'unnamed')} at {timestamp}")
|
|
329
|
+
if event.get("attributes"):
|
|
330
|
+
for key, value in event["attributes"].items():
|
|
331
|
+
# Special handling for stack traces
|
|
332
|
+
if key == "exception.stacktrace" and isinstance(value, str):
|
|
333
|
+
click.echo(f" {key}:")
|
|
334
|
+
lines = value.split("\n")[:10] # Show first 10 lines
|
|
335
|
+
for line in lines:
|
|
336
|
+
click.echo(f" {line}")
|
|
337
|
+
if len(value.split("\n")) > 10:
|
|
338
|
+
click.echo(" ... (truncated)")
|
|
339
|
+
else:
|
|
340
|
+
click.echo(f" {key}: {value}")
|
|
341
|
+
|
|
342
|
+
# Show links
|
|
343
|
+
if span.links:
|
|
344
|
+
click.echo()
|
|
345
|
+
click.secho("Links:", fg="bright_blue", bold=True)
|
|
346
|
+
for link in span.links:
|
|
347
|
+
click.echo(
|
|
348
|
+
f" Trace: {link.get('context', {}).get('trace_id', 'unknown')}"
|
|
349
|
+
)
|
|
350
|
+
click.echo(
|
|
351
|
+
f" Span: {link.get('context', {}).get('span_id', 'unknown')}"
|
|
352
|
+
)
|
|
353
|
+
if link.get("attributes"):
|
|
354
|
+
for key, value in link["attributes"].items():
|
|
355
|
+
click.echo(f" {key}: {value}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def format_trace_output(trace):
|
|
359
|
+
"""Format trace output for display - extracted for reuse."""
|
|
360
|
+
output_lines = []
|
|
361
|
+
|
|
362
|
+
# Trace details with aligned labels
|
|
363
|
+
label_width = 12
|
|
364
|
+
output_lines.append(
|
|
365
|
+
click.style(
|
|
366
|
+
f"{'Trace:':<{label_width}} {trace.trace_id}", fg="bright_blue", bold=True
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
output_lines.append(
|
|
370
|
+
f"{'Start:':<{label_width}} {trace.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
|
|
371
|
+
)
|
|
372
|
+
output_lines.append(
|
|
373
|
+
f"{'End:':<{label_width}} {trace.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
|
|
374
|
+
)
|
|
375
|
+
output_lines.append(f"{'Duration:':<{label_width}} {trace.duration_ms():.2f}ms")
|
|
376
|
+
|
|
377
|
+
if trace.summary:
|
|
378
|
+
output_lines.append(f"{'Summary:':<{label_width}} {trace.summary}")
|
|
379
|
+
|
|
380
|
+
if trace.request_id:
|
|
381
|
+
output_lines.append(f"{'Request ID:':<{label_width}} {trace.request_id}")
|
|
382
|
+
if trace.user_id:
|
|
383
|
+
output_lines.append(f"{'User ID:':<{label_width}} {trace.user_id}")
|
|
384
|
+
if trace.session_id:
|
|
385
|
+
output_lines.append(f"{'Session ID:':<{label_width}} {trace.session_id}")
|
|
386
|
+
|
|
387
|
+
output_lines.append("")
|
|
388
|
+
output_lines.append(click.style("Spans:", fg="bright_blue", bold=True))
|
|
389
|
+
|
|
390
|
+
# Get annotated spans with nesting levels
|
|
391
|
+
spans = trace.spans.all().annotate_spans()
|
|
392
|
+
|
|
393
|
+
# Build parent-child relationships
|
|
394
|
+
span_dict = {span.span_id: span for span in spans}
|
|
395
|
+
children = {}
|
|
396
|
+
for span in spans:
|
|
397
|
+
if span.parent_id:
|
|
398
|
+
if span.parent_id not in children:
|
|
399
|
+
children[span.parent_id] = []
|
|
400
|
+
children[span.parent_id].append(span.span_id)
|
|
401
|
+
|
|
402
|
+
def format_span_tree(span, level=0):
|
|
403
|
+
lines = []
|
|
404
|
+
# Simple 4-space indentation
|
|
405
|
+
prefix = " " * level
|
|
406
|
+
|
|
407
|
+
# Span name with duration and status
|
|
408
|
+
duration = span.duration_ms()
|
|
409
|
+
|
|
410
|
+
# Determine status icon
|
|
411
|
+
status_icon = ""
|
|
412
|
+
if span.status:
|
|
413
|
+
if span.status in ["STATUS_CODE_OK", "OK"]:
|
|
414
|
+
status_icon = " ✓"
|
|
415
|
+
elif span.status not in ["STATUS_CODE_UNSET", "UNSET"]:
|
|
416
|
+
status_icon = " ✗"
|
|
417
|
+
|
|
418
|
+
# Color based on span kind, but red if error
|
|
419
|
+
if span.status and span.status not in [
|
|
420
|
+
"STATUS_CODE_OK",
|
|
421
|
+
"STATUS_CODE_UNSET",
|
|
422
|
+
"OK",
|
|
423
|
+
"UNSET",
|
|
424
|
+
]:
|
|
425
|
+
color = "red"
|
|
426
|
+
else:
|
|
427
|
+
color_map = {
|
|
428
|
+
"SERVER": "green",
|
|
429
|
+
"CLIENT": "cyan",
|
|
430
|
+
"INTERNAL": "white",
|
|
431
|
+
"PRODUCER": "magenta",
|
|
432
|
+
"CONSUMER": "yellow",
|
|
433
|
+
}
|
|
434
|
+
color = color_map.get(span.kind, "white")
|
|
435
|
+
|
|
436
|
+
# Build span line
|
|
437
|
+
span_line = (
|
|
438
|
+
prefix
|
|
439
|
+
+ click.style(span.name, fg=color, bold=True, underline=True)
|
|
440
|
+
+ click.style(f" ({duration:.2f}ms){status_icon}", fg=color, bold=True)
|
|
441
|
+
+ click.style(f" [{span.span_id}]", fg="bright_black")
|
|
442
|
+
)
|
|
443
|
+
lines.append(span_line)
|
|
444
|
+
|
|
445
|
+
# Show additional details with proper indentation
|
|
446
|
+
detail_prefix = " " * (level + 1)
|
|
447
|
+
|
|
448
|
+
# Show SQL queries
|
|
449
|
+
if span.sql_query:
|
|
450
|
+
lines.append(
|
|
451
|
+
f"{detail_prefix}SQL: {span.sql_query[:80]}{'...' if len(span.sql_query) > 80 else ''}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Show annotations (like duplicate queries)
|
|
455
|
+
for annotation in span.annotations:
|
|
456
|
+
severity_color = "yellow" if annotation["severity"] == "warning" else "red"
|
|
457
|
+
lines.append(
|
|
458
|
+
click.style(
|
|
459
|
+
f"{detail_prefix}⚠️ {annotation['message']}", fg=severity_color
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Show exceptions
|
|
464
|
+
if stacktrace := span.get_exception_stacktrace():
|
|
465
|
+
lines.append(click.style(f"{detail_prefix}❌ Exception occurred", fg="red"))
|
|
466
|
+
# Show first few lines of stacktrace
|
|
467
|
+
stack_lines = stacktrace.split("\n")[:3]
|
|
468
|
+
for line in stack_lines:
|
|
469
|
+
if line.strip():
|
|
470
|
+
lines.append(f"{detail_prefix} {line.strip()}")
|
|
471
|
+
|
|
472
|
+
# Format children recursively
|
|
473
|
+
if span.span_id in children:
|
|
474
|
+
child_ids = children[span.span_id]
|
|
475
|
+
for child_id in child_ids:
|
|
476
|
+
child_span = span_dict[child_id]
|
|
477
|
+
lines.extend(format_span_tree(child_span, level + 1))
|
|
478
|
+
|
|
479
|
+
return lines
|
|
480
|
+
|
|
481
|
+
# Start with root spans (spans without parents)
|
|
482
|
+
root_spans = [span for span in spans if not span.parent_id]
|
|
483
|
+
for root_span in root_spans:
|
|
484
|
+
output_lines.extend(format_span_tree(root_span, 0))
|
|
485
|
+
|
|
486
|
+
return "\n".join(output_lines)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@observer_cli.command("diagnose")
|
|
490
|
+
@click.argument("trace_id", required=False)
|
|
491
|
+
@click.option("--url", help="Fetch trace from a shareable URL")
|
|
492
|
+
@click.option(
|
|
493
|
+
"--json", "json_input", help="Provide trace JSON data (use '-' for stdin)"
|
|
494
|
+
)
|
|
495
|
+
@click.option(
|
|
496
|
+
"--agent-command",
|
|
497
|
+
envvar="PLAIN_AGENT_COMMAND",
|
|
498
|
+
help="Run command with generated prompt",
|
|
499
|
+
)
|
|
500
|
+
def diagnose(trace_id, url, json_input, agent_command):
|
|
501
|
+
"""Generate a diagnostic prompt for analyzing a trace.
|
|
502
|
+
|
|
503
|
+
By default, provide a trace ID from the database. Use --url for a shareable
|
|
504
|
+
trace URL, or --json for raw trace data (--json - reads from stdin).
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
input_count = sum(bool(x) for x in [trace_id, url, json_input])
|
|
508
|
+
if input_count == 0:
|
|
509
|
+
raise click.UsageError("Must provide trace ID, --url, or --json")
|
|
510
|
+
elif input_count > 1:
|
|
511
|
+
raise click.UsageError("Cannot specify multiple input methods")
|
|
512
|
+
|
|
513
|
+
if json_input:
|
|
514
|
+
if json_input == "-":
|
|
515
|
+
try:
|
|
516
|
+
json_data = sys.stdin.read()
|
|
517
|
+
trace_data = json.loads(json_data)
|
|
518
|
+
except json.JSONDecodeError as e:
|
|
519
|
+
raise click.ClickException(f"Error parsing JSON from stdin: {e}")
|
|
520
|
+
except Exception as e:
|
|
521
|
+
raise click.ClickException(f"Error reading from stdin: {e}")
|
|
522
|
+
else:
|
|
523
|
+
try:
|
|
524
|
+
trace_data = json.loads(json_input)
|
|
525
|
+
except json.JSONDecodeError as e:
|
|
526
|
+
raise click.ClickException(f"Error parsing JSON: {e}")
|
|
527
|
+
elif url:
|
|
528
|
+
try:
|
|
529
|
+
request = urllib.request.Request(
|
|
530
|
+
url, headers={"Accept": "application/json"}
|
|
531
|
+
)
|
|
532
|
+
with urllib.request.urlopen(request) as response:
|
|
533
|
+
trace_data = json.loads(response.read().decode())
|
|
534
|
+
except Exception as e:
|
|
535
|
+
raise click.ClickException(f"Error fetching trace from URL: {e}")
|
|
536
|
+
else:
|
|
537
|
+
try:
|
|
538
|
+
trace = Trace.objects.get(trace_id=trace_id)
|
|
539
|
+
trace_data = trace.as_dict()
|
|
540
|
+
except Trace.DoesNotExist:
|
|
541
|
+
raise click.ClickException(f"Trace with ID '{trace_id}' not found")
|
|
542
|
+
|
|
543
|
+
prompt_lines = [
|
|
544
|
+
"I have an OpenTelemetry trace data JSON from a Plain application. Analyze it for performance issues or improvements.",
|
|
545
|
+
"",
|
|
546
|
+
"Focus on easy and obvious wins first and foremost. If there is nothing obvious, that's ok! Tell me that and ask whether there are specific things we should look deeper into.",
|
|
547
|
+
"",
|
|
548
|
+
"If potential code changes are found, briefly explain them and ask whether we should implement them.",
|
|
549
|
+
"",
|
|
550
|
+
"## Trace Data JSON",
|
|
551
|
+
"",
|
|
552
|
+
"```json",
|
|
553
|
+
json.dumps(trace_data, indent=2),
|
|
554
|
+
"```",
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
prompt = "\n".join(prompt_lines)
|
|
558
|
+
|
|
559
|
+
if agent_command:
|
|
560
|
+
cmd = shlex.split(agent_command)
|
|
561
|
+
cmd.append(prompt)
|
|
562
|
+
result = subprocess.run(cmd, check=False)
|
|
563
|
+
if result.returncode != 0:
|
|
564
|
+
click.secho(
|
|
565
|
+
f"Agent command failed with exit code {result.returncode}",
|
|
566
|
+
fg="red",
|
|
567
|
+
err=True,
|
|
568
|
+
)
|
|
569
|
+
else:
|
|
570
|
+
click.echo(prompt)
|
|
571
|
+
click.secho(
|
|
572
|
+
"\nCopy the prompt above to a coding agent. To run an agent automatically, use --agent-command or set the PLAIN_AGENT_COMMAND environment variable.",
|
|
573
|
+
dim=True,
|
|
574
|
+
italic=True,
|
|
575
|
+
err=True,
|
|
576
|
+
)
|
|
@@ -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
|
+
]
|