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.

@@ -1,5 +1,31 @@
1
1
  # plain-observer changelog
2
2
 
3
+ ## [0.3.0](https://github.com/dropseed/plain/releases/plain-observer@0.3.0) (2025-07-22)
4
+
5
+ ### What's changed
6
+
7
+ - Database models now use the new `PrimaryKeyField` instead of `BigAutoField` for primary keys ([4b8fa6a](https://github.com/dropseed/plain/commit/4b8fa6aef1))
8
+ - Admin interface updated to use `id` instead of `pk` for ordering and references ([4b8fa6a](https://github.com/dropseed/plain/commit/4b8fa6aef1))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.2.0](https://github.com/dropseed/plain/releases/plain-observer@0.2.0) (2025-07-21)
15
+
16
+ ### What's changed
17
+
18
+ - 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))
19
+ - Added trace sharing functionality allowing traces to be shared via public URLs ([90f916b](https://github.com/dropseed/plain/commit/90f916b676))
20
+ - Added `plain observer diagnose` command with JSON and URL output options for troubleshooting ([71936e88a5](https://github.com/dropseed/plain/commit/71936e88a5))
21
+ - Improved trace detail UI with better formatting and navigation ([90f916b](https://github.com/dropseed/plain/commit/90f916b676))
22
+ - Removed the custom trace detail UI from the admin interface, now uses standard admin detail view ([0c277fc](https://github.com/dropseed/plain/commit/0c277fc076))
23
+ - Enhanced raw agent prompt output styling ([684f208](https://github.com/dropseed/plain/commit/684f2087fc))
24
+
25
+ ### Upgrade instructions
26
+
27
+ - No changes required
28
+
3
29
  ## [0.1.0](https://github.com/dropseed/plain/releases/plain-observer@0.1.0) (2025-07-19)
4
30
 
5
31
  ### What's changed
plain/observer/admin.py CHANGED
@@ -28,20 +28,12 @@ class TraceViewset(AdminViewset):
28
28
  # Actually want a button to delete ALL! not possible yet
29
29
  # actions = ["Delete"]
30
30
 
31
- # def perform_action(self, action: str, target_pks: list):
31
+ # def perform_action(self, action: str, target_ids: list):
32
32
  # if action == "Delete":
33
- # Trace.objects.filter(id__in=target_pks).delete()
33
+ # Trace.objects.filter(id__in=target_ids).delete()
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
@@ -57,7 +49,7 @@ class SpanViewset(AdminViewset):
57
49
  "parent_id",
58
50
  "start_time",
59
51
  ]
60
- queryset_order = ["-pk"]
52
+ queryset_order = ["-id"]
61
53
  allow_global_search = False
62
54
  displays = ["Parents only"]
63
55
  search_fields = ["name", "span_id", "parent_id"]
plain/observer/cli.py CHANGED
@@ -1,7 +1,13 @@
1
+ import json
2
+ import shlex
3
+ import subprocess
4
+ import sys
5
+ import urllib.request
6
+
1
7
  import click
2
8
 
3
9
  from plain.cli import register_cli
4
- from plain.observer.models import Trace
10
+ from plain.observer.models import Span, Trace
5
11
 
6
12
 
7
13
  @register_cli("observer")
@@ -14,10 +20,557 @@ def observer_cli():
14
20
  @click.option("--force", is_flag=True, help="Skip confirmation prompt.")
15
21
  def clear(force: bool):
16
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
+
17
30
  if not force:
18
- click.confirm(
19
- "Are you sure you want to clear all observer data? This cannot be undone.",
20
- abort=True,
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
21
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")
22
435
 
23
- print("Deleted", Trace.objects.all().delete())
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
+ )
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
14
14
  migrations.CreateModel(
15
15
  name="Trace",
16
16
  fields=[
17
- ("id", models.BigAutoField(auto_created=True, primary_key=True)),
17
+ ("id", models.PrimaryKeyField()),
18
18
  ("trace_id", models.CharField(max_length=255)),
19
19
  ("start_time", models.DateTimeField()),
20
20
  ("end_time", models.DateTimeField()),
@@ -39,7 +39,7 @@ class Migration(migrations.Migration):
39
39
  migrations.CreateModel(
40
40
  name="Span",
41
41
  fields=[
42
- ("id", models.BigAutoField(auto_created=True, primary_key=True)),
42
+ ("id", models.PrimaryKeyField()),
43
43
  ("span_id", models.CharField(max_length=255)),
44
44
  ("name", models.CharField(max_length=255)),
45
45
  ("kind", models.CharField(max_length=50)),