plain.observer 0.8.0__tar.gz → 0.9.1__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.

Files changed (34) hide show
  1. {plain_observer-0.8.0 → plain_observer-0.9.1}/.gitignore +1 -0
  2. {plain_observer-0.8.0 → plain_observer-0.9.1}/PKG-INFO +1 -1
  3. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/CHANGELOG.md +22 -0
  4. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/admin.py +11 -6
  5. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/cli.py +37 -19
  6. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/config.py +9 -5
  7. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/core.py +14 -10
  8. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/logging.py +15 -5
  9. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/models.py +66 -46
  10. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/otel.py +85 -48
  11. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/toolbar.py +5 -2
  12. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/views.py +26 -19
  13. {plain_observer-0.8.0 → plain_observer-0.9.1}/pyproject.toml +1 -1
  14. {plain_observer-0.8.0 → plain_observer-0.9.1}/LICENSE +0 -0
  15. {plain_observer-0.8.0 → plain_observer-0.9.1}/README.md +0 -0
  16. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/README.md +0 -0
  17. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/__init__.py +0 -0
  18. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/default_settings.py +0 -0
  19. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0001_initial.py +0 -0
  20. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +0 -0
  21. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
  22. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +0 -0
  23. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +0 -0
  24. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0006_remove_log_logger.py +0 -0
  25. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/__init__.py +0 -0
  26. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/log.html +0 -0
  27. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/span.html +0 -0
  28. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace.html +0 -0
  29. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_detail.html +0 -0
  30. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_share.html +0 -0
  31. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/traces.html +0 -0
  32. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer.html +0 -0
  33. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer_button.html +0 -0
  34. {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/urls.py +0 -0
@@ -16,3 +16,4 @@ plain*/tests/.plain
16
16
 
17
17
  .vscode
18
18
  /.claude
19
+ /.benchmarks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.observer
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: On-page telemetry and observability tools for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -1,5 +1,27 @@
1
1
  # plain-observer changelog
2
2
 
3
+ ## [0.9.1](https://github.com/dropseed/plain/releases/plain-observer@0.9.1) (2025-10-06)
4
+
5
+ ### What's changed
6
+
7
+ - Added comprehensive type annotations throughout the package for improved IDE support and type checking ([ffb8624](https://github.com/dropseed/plain/commit/ffb8624d6f))
8
+ - Package has been validated with 100% type coverage and added to the type validation script ([ffb8624](https://github.com/dropseed/plain/commit/ffb8624d6f))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.9.0](https://github.com/dropseed/plain/releases/plain-observer@0.9.0) (2025-09-30)
15
+
16
+ ### What's changed
17
+
18
+ - Settings renamed from `APP_NAME` to `NAME` and `APP_VERSION` to `VERSION` for consistency with Plain conventions ([4c5f216](https://github.com/dropseed/plain/commit/4c5f2166c1))
19
+ - Trace detail and shared views now use `request.get_preferred_type()` for improved content negotiation ([b105ba4](https://github.com/dropseed/plain/commit/b105ba4dd0))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required
24
+
3
25
  ## [0.8.0](https://github.com/dropseed/plain/releases/plain-observer@0.8.0) (2025-09-30)
4
26
 
5
27
  ### What's changed
@@ -1,3 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from plain import models
1
6
  from plain.admin.views import (
2
7
  AdminModelDetailView,
3
8
  AdminModelListView,
@@ -24,7 +29,7 @@ class TraceViewset(AdminViewset):
24
29
  allow_global_search = False
25
30
  actions = ["Delete"]
26
31
 
27
- def perform_action(self, action: str, target_ids: list):
32
+ def perform_action(self, action: str, target_ids: Sequence[int]) -> None:
28
33
  if action == "Delete":
29
34
  Trace.query.filter(id__in=target_ids).delete()
30
35
 
@@ -52,11 +57,11 @@ class SpanViewset(AdminViewset):
52
57
  search_fields = ["name", "span_id", "parent_id"]
53
58
  actions = ["Delete"]
54
59
 
55
- def perform_action(self, action: str, target_ids: list):
60
+ def perform_action(self, action: str, target_ids: Sequence[int]) -> None:
56
61
  if action == "Delete":
57
62
  Span.query.filter(id__in=target_ids).delete()
58
63
 
59
- def get_objects(self):
64
+ def get_objects(self) -> models.QuerySet:
60
65
  return (
61
66
  super()
62
67
  .get_objects()
@@ -69,7 +74,7 @@ class SpanViewset(AdminViewset):
69
74
  )
70
75
  )
71
76
 
72
- def get_initial_queryset(self):
77
+ def get_initial_queryset(self) -> models.QuerySet:
73
78
  queryset = super().get_initial_queryset()
74
79
  if self.display == "Parents only":
75
80
  queryset = queryset.filter(parent_id="")
@@ -99,13 +104,13 @@ class LogViewset(AdminViewset):
99
104
  filters = ["level", "logger"]
100
105
  actions = ["Delete selected", "Delete all"]
101
106
 
102
- def perform_action(self, action: str, target_ids: list):
107
+ def perform_action(self, action: str, target_ids: Sequence[int]) -> None:
103
108
  if action == "Delete selected":
104
109
  Log.query.filter(id__in=target_ids).delete()
105
110
  elif action == "Delete all":
106
111
  Log.query.all().delete()
107
112
 
108
- def get_objects(self):
113
+ def get_objects(self) -> models.QuerySet:
109
114
  return (
110
115
  super()
111
116
  .get_objects()
@@ -1,6 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import sys
3
5
  import urllib.request
6
+ from datetime import datetime
7
+ from typing import Any, cast
4
8
 
5
9
  import click
6
10
 
@@ -11,13 +15,13 @@ from plain.observer.models import Span, Trace
11
15
 
12
16
  @register_cli("observer")
13
17
  @click.group("observer")
14
- def observer_cli():
18
+ def observer_cli() -> None:
15
19
  pass
16
20
 
17
21
 
18
22
  @observer_cli.command()
19
23
  @click.option("--force", is_flag=True, help="Skip confirmation prompt.")
20
- def clear(force: bool):
24
+ def clear(force: bool) -> None:
21
25
  """Clear all observer data."""
22
26
  query = Trace.query.all()
23
27
  trace_count = query.count()
@@ -40,7 +44,13 @@ def clear(force: bool):
40
44
  @click.option("--request-id", help="Filter by request ID")
41
45
  @click.option("--session-id", help="Filter by session ID")
42
46
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
43
- def trace_list(limit, user_id, request_id, session_id, output_json):
47
+ def trace_list(
48
+ limit: int,
49
+ user_id: str | None,
50
+ request_id: str | None,
51
+ session_id: str | None,
52
+ output_json: bool,
53
+ ) -> None:
44
54
  """List recent traces."""
45
55
  # Build query
46
56
  query = Trace.query.all()
@@ -136,7 +146,7 @@ def trace_list(limit, user_id, request_id, session_id, output_json):
136
146
  @observer_cli.command("trace")
137
147
  @click.argument("trace_id")
138
148
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
139
- def trace_detail(trace_id, output_json):
149
+ def trace_detail(trace_id: str, output_json: bool) -> None:
140
150
  """Display detailed information about a specific trace."""
141
151
  try:
142
152
  trace = Trace.query.get(trace_id=trace_id)
@@ -154,7 +164,7 @@ def trace_detail(trace_id, output_json):
154
164
  @click.option("--trace-id", help="Filter by trace ID")
155
165
  @click.option("--limit", default=50, help="Number of spans to show (default: 50)")
156
166
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
157
- def span_list(trace_id, limit, output_json):
167
+ def span_list(trace_id: str | None, limit: int, output_json: bool) -> None:
158
168
  """List recent spans."""
159
169
  # Build query
160
170
  query = Span.query.all()
@@ -256,7 +266,7 @@ def span_list(trace_id, limit, output_json):
256
266
  @observer_cli.command("span")
257
267
  @click.argument("span_id")
258
268
  @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
259
- def span_detail(span_id, output_json):
269
+ def span_detail(span_id: str, output_json: bool) -> None:
260
270
  """Display detailed information about a specific span."""
261
271
  try:
262
272
  span = Span.query.select_related("trace").get(span_id=span_id)
@@ -354,22 +364,24 @@ def span_detail(span_id, output_json):
354
364
  click.echo(f" {key}: {value}")
355
365
 
356
366
 
357
- def format_trace_output(trace):
367
+ def format_trace_output(trace: Trace) -> str:
358
368
  """Format trace output for display - extracted for reuse."""
359
- output_lines = []
369
+ output_lines: list[str] = []
360
370
 
361
371
  # Trace details with aligned labels
362
372
  label_width = 12
373
+ start_time = cast(datetime, trace.start_time)
374
+ end_time = cast(datetime, trace.end_time)
363
375
  output_lines.append(
364
376
  click.style(
365
377
  f"{'Trace:':<{label_width}} {trace.trace_id}", fg="bright_blue", bold=True
366
378
  )
367
379
  )
368
380
  output_lines.append(
369
- f"{'Start:':<{label_width}} {trace.start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
381
+ f"{'Start:':<{label_width}} {start_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
370
382
  )
371
383
  output_lines.append(
372
- f"{'End:':<{label_width}} {trace.end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
384
+ f"{'End:':<{label_width}} {end_time.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC"
373
385
  )
374
386
  output_lines.append(f"{'Duration:':<{label_width}} {trace.duration_ms():.2f}ms")
375
387
 
@@ -387,19 +399,17 @@ def format_trace_output(trace):
387
399
  output_lines.append(click.style("Spans:", fg="bright_blue", bold=True))
388
400
 
389
401
  # Get annotated spans with nesting levels
390
- spans = trace.spans.query.all().annotate_spans()
402
+ spans = trace.spans.query.all().annotate_spans() # type: ignore[attr-defined]
391
403
 
392
404
  # Build parent-child relationships
393
405
  span_dict = {span.span_id: span for span in spans}
394
- children = {}
406
+ children: dict[str, list[str]] = {}
395
407
  for span in spans:
396
408
  if span.parent_id:
397
- if span.parent_id not in children:
398
- children[span.parent_id] = []
399
- children[span.parent_id].append(span.span_id)
409
+ children.setdefault(span.parent_id, []).append(span.span_id)
400
410
 
401
- def format_span_tree(span, level=0):
402
- lines = []
411
+ def format_span_tree(span: Span, level: int = 0) -> list[str]:
412
+ lines: list[str] = []
403
413
  # Simple 4-space indentation
404
414
  prefix = " " * level
405
415
 
@@ -502,7 +512,13 @@ def format_trace_output(trace):
502
512
  is_flag=True,
503
513
  help="Print the prompt without running the agent",
504
514
  )
505
- def diagnose(trace_id, url, json_input, agent_command, print_only):
515
+ def diagnose(
516
+ trace_id: str | None,
517
+ url: str | None,
518
+ json_input: str | None,
519
+ agent_command: str | None,
520
+ print_only: bool,
521
+ ) -> None:
506
522
  """Generate a diagnostic prompt for analyzing a trace.
507
523
 
508
524
  By default, provide a trace ID from the database. Use --url for a shareable
@@ -515,6 +531,8 @@ def diagnose(trace_id, url, json_input, agent_command, print_only):
515
531
  elif input_count > 1:
516
532
  raise click.UsageError("Cannot specify multiple input methods")
517
533
 
534
+ trace_data: Any
535
+
518
536
  if json_input:
519
537
  if json_input == "-":
520
538
  try:
@@ -545,7 +563,7 @@ def diagnose(trace_id, url, json_input, agent_command, print_only):
545
563
  except Trace.DoesNotExist:
546
564
  raise click.ClickException(f"Trace with ID '{trace_id}' not found")
547
565
 
548
- prompt_lines = [
566
+ prompt_lines: list[str] = [
549
567
  "I have an OpenTelemetry trace data JSON from a Plain application. Analyze it for performance issues or improvements.",
550
568
  "",
551
569
  "Focus on easy and obvious wins first and foremost. You have access to the codebase, so make sure you look at it before suggesting anything! If there is nothing obvious, that's ok -- tell me that and ask whether there are specific things we should look deeper into.",
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
1
5
  from opentelemetry import trace
2
6
  from opentelemetry.sdk.resources import Resource
3
7
  from opentelemetry.sdk.trace import TracerProvider
@@ -20,7 +24,7 @@ from .otel import (
20
24
  class Config(PackageConfig):
21
25
  package_label = "plainobserver"
22
26
 
23
- def ready(self):
27
+ def ready(self) -> None:
24
28
  sampler = ObserverSampler()
25
29
  span_processor = ObserverSpanProcessor()
26
30
 
@@ -36,8 +40,8 @@ class Config(PackageConfig):
36
40
  # Start our own provider, new sampler, and span processor
37
41
  resource = Resource.create(
38
42
  {
39
- service_attributes.SERVICE_NAME: settings.APP_NAME,
40
- service_attributes.SERVICE_VERSION: settings.APP_VERSION,
43
+ service_attributes.SERVICE_NAME: settings.NAME,
44
+ service_attributes.SERVICE_VERSION: settings.VERSION,
41
45
  }
42
46
  )
43
47
  provider = TracerProvider(sampler=sampler, resource=resource)
@@ -55,11 +59,11 @@ class Config(PackageConfig):
55
59
  app_logger.addHandler(observer_log_handler)
56
60
 
57
61
  @staticmethod
58
- def get_existing_trace_provider():
62
+ def get_existing_trace_provider() -> TracerProvider | None:
59
63
  """Return the currently configured provider if set."""
60
64
  current_provider = trace.get_tracer_provider()
61
65
  if current_provider and not isinstance(
62
66
  current_provider, trace.ProxyTracerProvider
63
67
  ):
64
- return current_provider
68
+ return cast(TracerProvider, current_provider)
65
69
  return None
@@ -1,5 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import Enum
2
4
 
5
+ from plain.http import Request, Response
6
+
3
7
 
4
8
  class ObserverMode(Enum):
5
9
  """Observer operation modes."""
@@ -16,30 +20,30 @@ class Observer:
16
20
  SUMMARY_COOKIE_DURATION = 60 * 60 * 24 * 7 # 1 week in seconds
17
21
  PERSIST_COOKIE_DURATION = 60 * 60 * 24 # 1 day in seconds
18
22
 
19
- def __init__(self, request):
23
+ def __init__(self, request: Request) -> None:
20
24
  self.request = request
21
25
 
22
- def mode(self):
26
+ def mode(self) -> str | None:
23
27
  """Get the current observer mode from signed cookie."""
24
28
  return self.request.get_signed_cookie(self.COOKIE_NAME, default=None)
25
29
 
26
- def is_enabled(self):
30
+ def is_enabled(self) -> bool:
27
31
  """Check if observer is enabled (either summary or persist mode)."""
28
32
  return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
29
33
 
30
- def is_persisting(self):
34
+ def is_persisting(self) -> bool:
31
35
  """Check if full persisting (with DB export) is enabled."""
32
36
  return self.mode() == ObserverMode.PERSIST.value
33
37
 
34
- def is_summarizing(self):
38
+ def is_summarizing(self) -> bool:
35
39
  """Check if summary mode is enabled."""
36
40
  return self.mode() == ObserverMode.SUMMARY.value
37
41
 
38
- def is_disabled(self):
42
+ def is_disabled(self) -> bool:
39
43
  """Check if observer is explicitly disabled."""
40
44
  return self.mode() == ObserverMode.DISABLED.value
41
45
 
42
- def enable_summary_mode(self, response):
46
+ def enable_summary_mode(self, response: Response) -> None:
43
47
  """Enable summary mode (real-time monitoring, no DB export)."""
44
48
  response.set_signed_cookie(
45
49
  self.COOKIE_NAME,
@@ -47,7 +51,7 @@ class Observer:
47
51
  max_age=self.SUMMARY_COOKIE_DURATION,
48
52
  )
49
53
 
50
- def enable_persist_mode(self, response):
54
+ def enable_persist_mode(self, response: Response) -> None:
51
55
  """Enable full persist mode (real-time monitoring + DB export)."""
52
56
  response.set_signed_cookie(
53
57
  self.COOKIE_NAME,
@@ -55,7 +59,7 @@ class Observer:
55
59
  max_age=self.PERSIST_COOKIE_DURATION,
56
60
  )
57
61
 
58
- def disable(self, response):
62
+ def disable(self, response: Response) -> None:
59
63
  """Disable observer by setting cookie to disabled."""
60
64
  response.set_signed_cookie(
61
65
  self.COOKIE_NAME,
@@ -63,7 +67,7 @@ class Observer:
63
67
  max_age=self.PERSIST_COOKIE_DURATION,
64
68
  )
65
69
 
66
- def get_current_trace_summary(self):
70
+ def get_current_trace_summary(self) -> str | None:
67
71
  """Get performance summary string for the currently active trace."""
68
72
  from .otel import get_current_trace_summary
69
73
 
@@ -1,6 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
2
4
  import threading
3
5
  from datetime import UTC, datetime
6
+ from typing import TypedDict
4
7
 
5
8
  from opentelemetry import trace
6
9
  from opentelemetry.trace import format_span_id, format_trace_id
@@ -9,15 +12,22 @@ from .core import ObserverMode
9
12
  from .otel import get_observer_span_processor
10
13
 
11
14
 
15
+ class ObserverLogEntry(TypedDict):
16
+ message: str
17
+ level: str
18
+ span_id: str
19
+ timestamp: datetime
20
+
21
+
12
22
  class ObserverLogHandler(logging.Handler):
13
23
  """Custom logging handler that captures logs during active traces when observer is enabled."""
14
24
 
15
- def __init__(self, level=logging.NOTSET):
25
+ def __init__(self, level: int = logging.NOTSET) -> None:
16
26
  super().__init__(level)
17
27
  self._logs_lock = threading.Lock()
18
- self._trace_logs = {} # trace_id -> list of log records
28
+ self._trace_logs: dict[str, list[ObserverLogEntry]] = {}
19
29
 
20
- def emit(self, record):
30
+ def emit(self, record: logging.LogRecord) -> None:
21
31
  """Emit a log record if we're in an active observer trace."""
22
32
  try:
23
33
  # Get the current span to determine if we're in an active trace
@@ -45,7 +55,7 @@ class ObserverLogHandler(logging.Handler):
45
55
  return
46
56
 
47
57
  # Store the formatted message with span context
48
- log_entry = {
58
+ log_entry: ObserverLogEntry = {
49
59
  "message": self.format(record),
50
60
  "level": record.levelname,
51
61
  "span_id": span_id,
@@ -65,7 +75,7 @@ class ObserverLogHandler(logging.Handler):
65
75
  # Don't let logging errors break the application
66
76
  pass
67
77
 
68
- def pop_logs_for_trace(self, trace_id):
78
+ def pop_logs_for_trace(self, trace_id: str) -> list[ObserverLogEntry]:
69
79
  """Get and remove all logs for a specific trace in one operation."""
70
80
  with self._logs_lock:
71
81
  return self._trace_logs.pop(trace_id, []).copy()
@@ -1,10 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import secrets
3
5
  from collections import Counter
6
+ from collections.abc import Iterable, Mapping, Sequence
4
7
  from datetime import UTC, datetime
5
8
  from functools import cached_property
9
+ from typing import TYPE_CHECKING, Any, cast
6
10
 
7
11
  import sqlparse
12
+ from opentelemetry.sdk.trace import ReadableSpan
8
13
  from opentelemetry.semconv._incubating.attributes import (
9
14
  exception_attributes,
10
15
  session_attributes,
@@ -52,6 +57,12 @@ class Trace(models.Model):
52
57
  share_id = models.CharField(max_length=32, default="", required=False)
53
58
  share_created_at = models.DateTimeField(allow_null=True, required=False)
54
59
 
60
+ if TYPE_CHECKING:
61
+ from plain.models.fields.related_managers import BaseRelatedManager
62
+
63
+ spans: BaseRelatedManager
64
+ logs: BaseRelatedManager
65
+
55
66
  class Meta:
56
67
  ordering = ["-start_time"]
57
68
  constraints = [
@@ -68,32 +79,32 @@ class Trace(models.Model):
68
79
  models.Index(fields=["session_id"]),
69
80
  ]
70
81
 
71
- def __str__(self):
82
+ def __str__(self) -> str:
72
83
  return self.trace_id
73
84
 
74
- def get_absolute_url(self):
85
+ def get_absolute_url(self) -> str:
75
86
  """Return the canonical URL for this trace."""
76
87
  return reverse("observer:trace_detail", trace_id=self.trace_id)
77
88
 
78
- def generate_share_id(self):
89
+ def generate_share_id(self) -> str:
79
90
  """Generate a unique share ID for this trace."""
80
91
  self.share_id = secrets.token_urlsafe(24)
81
92
  self.share_created_at = timezone.now()
82
93
  self.save(update_fields=["share_id", "share_created_at"])
83
94
  return self.share_id
84
95
 
85
- def remove_share_id(self):
96
+ def remove_share_id(self) -> None:
86
97
  """Remove the share ID from this trace."""
87
98
  self.share_id = ""
88
99
  self.share_created_at = None
89
100
  self.save(update_fields=["share_id", "share_created_at"])
90
101
 
91
- def duration_ms(self):
102
+ def duration_ms(self) -> float:
92
103
  return (self.end_time - self.start_time).total_seconds() * 1000
93
104
 
94
- def get_trace_summary(self, spans):
105
+ def get_trace_summary(self, spans: Iterable[Span]) -> str:
95
106
  # Count database queries with query text and track duplicates
96
- query_texts = []
107
+ query_texts: list[str] = []
97
108
  for span in spans:
98
109
  if query_text := span.attributes.get(db_attributes.DB_QUERY_TEXT):
99
110
  query_texts.append(query_text)
@@ -103,7 +114,7 @@ class Trace(models.Model):
103
114
  duplicate_count = sum(query_counts.values()) - len(query_counts)
104
115
 
105
116
  # Build summary: "n spans, n queries (n duplicates), Xms"
106
- parts = []
117
+ parts: list[str] = []
107
118
 
108
119
  # Queries count with duplicates
109
120
  if query_total > 0:
@@ -119,7 +130,7 @@ class Trace(models.Model):
119
130
  return " • ".join(parts)
120
131
 
121
132
  @classmethod
122
- def from_opentelemetry_spans(cls, spans):
133
+ def from_opentelemetry_spans(cls, spans: Sequence[ReadableSpan]) -> Trace:
123
134
  """Create a Trace instance from a list of OpenTelemetry spans."""
124
135
  # Get trace information from the first span
125
136
  first_span = spans[0]
@@ -182,12 +193,12 @@ class Trace(models.Model):
182
193
  request_id=request_id,
183
194
  user_id=user_id,
184
195
  session_id=session_id,
185
- app_name=app_name or getattr(settings, "APP_NAME", ""),
186
- app_version=app_version or getattr(settings, "APP_VERSION", ""),
196
+ app_name=app_name or settings.NAME,
197
+ app_version=app_version or settings.VERSION,
187
198
  root_span_name=root_span.name if root_span else "",
188
199
  )
189
200
 
190
- def as_dict(self):
201
+ def as_dict(self) -> dict[str, Any]:
191
202
  spans = [
192
203
  span.span_data for span in self.spans.query.all().order_by("start_time")
193
204
  ]
@@ -217,9 +228,9 @@ class Trace(models.Model):
217
228
  "logs": logs,
218
229
  }
219
230
 
220
- def get_timeline_events(self):
231
+ def get_timeline_events(self) -> list[dict[str, Any]]:
221
232
  """Get chronological list of spans and logs for unified timeline display."""
222
- events = []
233
+ events: list[dict[str, Any]] = []
223
234
 
224
235
  for span in self.spans.query.all().annotate_spans():
225
236
  events.append(
@@ -258,12 +269,12 @@ class Trace(models.Model):
258
269
 
259
270
 
260
271
  class SpanQuerySet(models.QuerySet):
261
- def annotate_spans(self):
272
+ def annotate_spans(self) -> list[Span]:
262
273
  """Annotate spans with nesting levels and duplicate query warnings."""
263
- spans = list(self.order_by("start_time"))
274
+ spans: list[Span] = list(self.order_by("start_time"))
264
275
 
265
276
  # Build span dictionary for parent lookups
266
- span_dict = {span.span_id: span for span in spans}
277
+ span_dict: dict[str, Span] = {span.span_id: span for span in spans}
267
278
 
268
279
  # Calculate nesting levels
269
280
  for span in spans:
@@ -275,7 +286,7 @@ class SpanQuerySet(models.QuerySet):
275
286
  parent_level = parent.level if parent else 0
276
287
  span.level = parent_level + 1
277
288
 
278
- query_counts = {}
289
+ query_counts: dict[str, int] = {}
279
290
 
280
291
  # First pass: count queries
281
292
  for span in spans:
@@ -283,7 +294,7 @@ class SpanQuerySet(models.QuerySet):
283
294
  query_counts[sql_query] = query_counts.get(sql_query, 0) + 1
284
295
 
285
296
  # Second pass: add annotations
286
- query_occurrences = {}
297
+ query_occurrences: dict[str, int] = {}
287
298
  for span in spans:
288
299
  span.annotations = []
289
300
 
@@ -334,8 +345,12 @@ class Span(models.Model):
334
345
  models.Index(fields=["start_time"]),
335
346
  ]
336
347
 
348
+ if TYPE_CHECKING:
349
+ level: int
350
+ annotations: list[dict[str, Any]]
351
+
337
352
  @classmethod
338
- def from_opentelemetry_span(cls, otel_span, trace):
353
+ def from_opentelemetry_span(cls, otel_span: ReadableSpan, trace: Trace) -> Span:
339
354
  """Create a Span instance from an OpenTelemetry span."""
340
355
 
341
356
  span_data = json.loads(otel_span.to_json())
@@ -357,51 +372,51 @@ class Span(models.Model):
357
372
  span_data=span_data,
358
373
  )
359
374
 
360
- def __str__(self):
375
+ def __str__(self) -> str:
361
376
  return self.span_id
362
377
 
363
378
  @property
364
- def attributes(self):
379
+ def attributes(self) -> Mapping[str, Any]:
365
380
  """Get attributes from span_data."""
366
- return self.span_data.get("attributes", {})
381
+ return cast(Mapping[str, Any], self.span_data.get("attributes", {}))
367
382
 
368
383
  @property
369
- def events(self):
384
+ def events(self) -> list[Mapping[str, Any]]:
370
385
  """Get events from span_data."""
371
- return self.span_data.get("events", [])
386
+ return cast(list[Mapping[str, Any]], self.span_data.get("events", []))
372
387
 
373
388
  @property
374
- def links(self):
389
+ def links(self) -> list[Mapping[str, Any]]:
375
390
  """Get links from span_data."""
376
- return self.span_data.get("links", [])
391
+ return cast(list[Mapping[str, Any]], self.span_data.get("links", []))
377
392
 
378
393
  @property
379
- def resource(self):
394
+ def resource(self) -> Mapping[str, Any]:
380
395
  """Get resource from span_data."""
381
- return self.span_data.get("resource", {})
396
+ return cast(Mapping[str, Any], self.span_data.get("resource", {}))
382
397
 
383
398
  @property
384
- def context(self):
399
+ def context(self) -> Mapping[str, Any]:
385
400
  """Get context from span_data."""
386
- return self.span_data.get("context", {})
401
+ return cast(Mapping[str, Any], self.span_data.get("context", {}))
387
402
 
388
- def duration_ms(self):
403
+ def duration_ms(self) -> float:
389
404
  if self.start_time and self.end_time:
390
405
  return (self.end_time - self.start_time).total_seconds() * 1000
391
406
  return 0
392
407
 
393
408
  @cached_property
394
- def sql_query(self):
409
+ def sql_query(self) -> str | None:
395
410
  """Get the SQL query if this span contains one."""
396
411
  return self.attributes.get(db_attributes.DB_QUERY_TEXT)
397
412
 
398
413
  @cached_property
399
- def sql_query_params(self):
414
+ def sql_query_params(self) -> dict[str, Any]:
400
415
  """Get query parameters from attributes that start with 'db.query.parameter.'"""
401
416
  if not self.attributes:
402
417
  return {}
403
418
 
404
- query_params = {}
419
+ query_params: dict[str, Any] = {}
405
420
  for key, value in self.attributes.items():
406
421
  if key.startswith(DB_QUERY_PARAMETER_TEMPLATE + "."):
407
422
  param_name = key.replace(DB_QUERY_PARAMETER_TEMPLATE + ".", "")
@@ -410,7 +425,7 @@ class Span(models.Model):
410
425
  return query_params
411
426
 
412
427
  @cached_property
413
- def source_code_location(self):
428
+ def source_code_location(self) -> dict[str, Any] | None:
414
429
  """Get the source code location attributes from this span."""
415
430
  if not self.attributes:
416
431
  return None
@@ -432,7 +447,7 @@ class Span(models.Model):
432
447
 
433
448
  return code_attrs if code_attrs else None
434
449
 
435
- def get_formatted_sql(self):
450
+ def get_formatted_sql(self) -> str | None:
436
451
  """Get the pretty-formatted SQL query if this span contains one."""
437
452
  sql = self.sql_query
438
453
  if not sql:
@@ -450,22 +465,27 @@ class Span(models.Model):
450
465
  comma_first=False,
451
466
  )
452
467
 
453
- def format_event_timestamp(self, timestamp):
468
+ def format_event_timestamp(
469
+ self, timestamp: float | int | datetime | str
470
+ ) -> datetime | str:
454
471
  """Convert event timestamp to a readable datetime."""
455
472
  if isinstance(timestamp, int | float):
473
+ ts_value = float(timestamp)
456
474
  try:
457
475
  # Try as seconds first
458
- if timestamp > 1e10: # Likely nanoseconds
459
- timestamp = timestamp / 1e9
460
- elif timestamp > 1e7: # Likely milliseconds
461
- timestamp = timestamp / 1e3
476
+ if ts_value > 1e10: # Likely nanoseconds
477
+ ts_value /= 1e9
478
+ elif ts_value > 1e7: # Likely milliseconds
479
+ ts_value /= 1e3
462
480
 
463
- return datetime.fromtimestamp(timestamp, tz=UTC)
481
+ return datetime.fromtimestamp(ts_value, tz=UTC)
464
482
  except (ValueError, OSError):
465
- return str(timestamp)
466
- return timestamp
483
+ return str(ts_value)
484
+ if isinstance(timestamp, datetime):
485
+ return timestamp
486
+ return str(timestamp)
467
487
 
468
- def get_exception_stacktrace(self):
488
+ def get_exception_stacktrace(self) -> str | None:
469
489
  """Get the exception stacktrace if this span has an exception event."""
470
490
  if not self.events:
471
491
  return None
@@ -1,13 +1,24 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
2
4
  import re
3
5
  import threading
4
6
  from collections import defaultdict
7
+ from collections.abc import MutableMapping, Sequence
8
+ from typing import TYPE_CHECKING, Any, cast
5
9
 
6
10
  import opentelemetry.context as context_api
7
11
  from opentelemetry import baggage, trace
8
- from opentelemetry.sdk.trace import SpanProcessor, sampling
12
+ from opentelemetry.context import Context
13
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, sampling
9
14
  from opentelemetry.semconv.attributes import url_attributes
10
- from opentelemetry.trace import SpanKind, format_span_id, format_trace_id
15
+ from opentelemetry.trace import (
16
+ Link,
17
+ SpanKind,
18
+ TraceState,
19
+ format_span_id,
20
+ format_trace_id,
21
+ )
11
22
 
12
23
  from plain.http.cookie import unsign_cookie_value
13
24
  from plain.logs import app_logger
@@ -16,10 +27,16 @@ from plain.runtime import settings
16
27
 
17
28
  from .core import Observer, ObserverMode
18
29
 
30
+ if TYPE_CHECKING:
31
+ from plain.observer.models import Span as ObserverSpanModel
32
+ from plain.observer.models import Trace as TraceModel
33
+
34
+ from .logging import ObserverLogEntry
35
+
19
36
  logger = logging.getLogger(__name__)
20
37
 
21
38
 
22
- def get_observer_span_processor():
39
+ def get_observer_span_processor() -> ObserverSpanProcessor | None:
23
40
  """Get the span collector instance from the tracer provider."""
24
41
  if not (current_provider := trace.get_tracer_provider()):
25
42
  return None
@@ -30,7 +47,11 @@ def get_observer_span_processor():
30
47
  # It's a composite processor, check its _span_processors
31
48
  if composite_processor := current_provider._active_span_processor:
32
49
  if hasattr(composite_processor, "_span_processors"):
33
- for processor in composite_processor._span_processors:
50
+ processors = cast(
51
+ Sequence[SpanProcessor],
52
+ getattr(composite_processor, "_span_processors", ()),
53
+ )
54
+ for processor in processors:
34
55
  if isinstance(processor, ObserverSpanProcessor):
35
56
  return processor
36
57
 
@@ -55,25 +76,25 @@ def get_current_trace_summary() -> str | None:
55
76
  class ObserverSampler(sampling.Sampler):
56
77
  """Samples traces based on request path and cookies."""
57
78
 
58
- def __init__(self):
79
+ def __init__(self) -> None:
59
80
  # Custom parent-based sampler
60
81
  self._delegate = sampling.ParentBased(sampling.ALWAYS_OFF)
61
82
 
62
83
  # TODO ignore url namespace instead? admin, observer, assets
63
- self._ignore_url_paths = [
84
+ self._ignore_url_paths: list[re.Pattern[str]] = [
64
85
  re.compile(p) for p in settings.OBSERVER_IGNORE_URL_PATTERNS
65
86
  ]
66
87
 
67
88
  def should_sample(
68
89
  self,
69
- parent_context,
70
- trace_id,
71
- name,
90
+ parent_context: Context | None,
91
+ trace_id: int,
92
+ name: str,
72
93
  kind: SpanKind | None = None,
73
- attributes=None,
74
- links=None,
75
- trace_state=None,
76
- ):
94
+ attributes: MutableMapping[str, Any] | None = None,
95
+ links: Sequence[Link] | None = None,
96
+ trace_state: TraceState | None = None,
97
+ ) -> sampling.SamplingResult:
77
98
  # First, drop if the URL should be ignored.
78
99
  if attributes:
79
100
  if url_path := attributes.get(url_attributes.URL_PATH, ""):
@@ -85,24 +106,27 @@ class ObserverSampler(sampling.Sampler):
85
106
  )
86
107
 
87
108
  # If no processor decision, check cookies directly for root spans
88
- decision = None
109
+ decision: sampling.Decision | None = None
89
110
  if parent_context:
90
111
  # Check cookies for sampling decision
91
- if cookies := baggage.get_baggage("http.request.cookies", parent_context):
92
- if observer_cookie := cookies.get(Observer.COOKIE_NAME):
93
- unsigned_value = unsign_cookie_value(
94
- Observer.COOKIE_NAME, observer_cookie, default=False
95
- )
112
+ cookies = cast(
113
+ MutableMapping[str, str] | None,
114
+ baggage.get_baggage("http.request.cookies", parent_context),
115
+ )
116
+ if cookies and (observer_cookie := cookies.get(Observer.COOKIE_NAME)):
117
+ unsigned_value = unsign_cookie_value(
118
+ Observer.COOKIE_NAME, observer_cookie, default=None
119
+ )
96
120
 
97
- if unsigned_value in (
98
- ObserverMode.PERSIST.value,
99
- ObserverMode.SUMMARY.value,
100
- ):
101
- # Always use RECORD_AND_SAMPLE so ParentBased works correctly
102
- # The processor will check the mode to decide whether to export
103
- decision = sampling.Decision.RECORD_AND_SAMPLE
104
- else:
105
- decision = sampling.Decision.DROP
121
+ if unsigned_value in (
122
+ ObserverMode.PERSIST.value,
123
+ ObserverMode.SUMMARY.value,
124
+ ):
125
+ # Always use RECORD_AND_SAMPLE so ParentBased works correctly
126
+ # The processor will check the mode to decide whether to export
127
+ decision = sampling.Decision.RECORD_AND_SAMPLE
128
+ else:
129
+ decision = sampling.Decision.DROP
106
130
 
107
131
  # If there are links, assume it is to another trace/span that we are keeping
108
132
  if links:
@@ -133,20 +157,20 @@ class ObserverSampler(sampling.Sampler):
133
157
  class ObserverCombinedSampler(sampling.Sampler):
134
158
  """Combine another sampler with ``ObserverSampler``."""
135
159
 
136
- def __init__(self, primary: sampling.Sampler, secondary: sampling.Sampler):
160
+ def __init__(self, primary: sampling.Sampler, secondary: sampling.Sampler) -> None:
137
161
  self.primary = primary
138
162
  self.secondary = secondary
139
163
 
140
164
  def should_sample(
141
165
  self,
142
- parent_context,
143
- trace_id,
144
- name,
166
+ parent_context: Context | None,
167
+ trace_id: int,
168
+ name: str,
145
169
  kind: SpanKind | None = None,
146
- attributes=None,
147
- links=None,
148
- trace_state=None,
149
- ):
170
+ attributes: MutableMapping[str, Any] | None = None,
171
+ links: Sequence[Link] | None = None,
172
+ trace_state: TraceState | None = None,
173
+ ) -> sampling.SamplingResult:
150
174
  result = self.primary.should_sample(
151
175
  parent_context,
152
176
  trace_id,
@@ -183,9 +207,9 @@ class ObserverSpanProcessor(SpanProcessor):
183
207
  database.
184
208
  """
185
209
 
186
- def __init__(self):
210
+ def __init__(self) -> None:
187
211
  # Span storage
188
- self._traces = defaultdict(
212
+ self._traces: defaultdict[str, dict[str, Any]] = defaultdict(
189
213
  lambda: {
190
214
  "trace": None, # Trace model instance
191
215
  "active_otel_spans": {}, # span_id -> opentelemetry span
@@ -196,11 +220,11 @@ class ObserverSpanProcessor(SpanProcessor):
196
220
  }
197
221
  )
198
222
  self._traces_lock = threading.Lock()
199
- self._ignore_url_paths = [
223
+ self._ignore_url_paths: list[re.Pattern[str]] = [
200
224
  re.compile(p) for p in settings.OBSERVER_IGNORE_URL_PATTERNS
201
225
  ]
202
226
 
203
- def on_start(self, span, parent_context=None):
227
+ def on_start(self, span: Any, parent_context: Context | None = None) -> None:
204
228
  """Called when a span starts."""
205
229
  trace_id = f"0x{format_trace_id(span.get_span_context().trace_id)}"
206
230
 
@@ -232,7 +256,7 @@ class ObserverSpanProcessor(SpanProcessor):
232
256
  if not span.parent:
233
257
  trace_info["root_span_id"] = span_id
234
258
 
235
- def on_end(self, span):
259
+ def on_end(self, span: ReadableSpan) -> None:
236
260
  """Called when a span ends."""
237
261
  trace_id = f"0x{format_trace_id(span.get_span_context().trace_id)}"
238
262
  span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
@@ -330,7 +354,13 @@ class ObserverSpanProcessor(SpanProcessor):
330
354
 
331
355
  return trace_info["trace"].get_trace_summary(span_models)
332
356
 
333
- def _export_trace(self, *, trace, spans, logs):
357
+ def _export_trace(
358
+ self,
359
+ *,
360
+ trace: TraceModel,
361
+ spans: Sequence[ObserverSpanModel],
362
+ logs: Sequence[ObserverLogEntry],
363
+ ) -> None:
334
364
  """Export trace, spans, and logs to the database."""
335
365
  from .models import Log, Span, Trace
336
366
 
@@ -342,7 +372,7 @@ class ObserverSpanProcessor(SpanProcessor):
342
372
  span.trace = trace
343
373
 
344
374
  # Bulk create spans
345
- Span.query.bulk_create(spans)
375
+ Span.query.bulk_create(spans) # type: ignore[arg-type]
346
376
 
347
377
  # Create log models if we have logs
348
378
  if logs:
@@ -387,7 +417,9 @@ class ObserverSpanProcessor(SpanProcessor):
387
417
  "Failed to clean up old observer traces: %s", e, exc_info=True
388
418
  )
389
419
 
390
- def _get_recording_mode(self, span, parent_context) -> str | None:
420
+ def _get_recording_mode(
421
+ self, span: Any, parent_context: Context | None
422
+ ) -> str | None:
391
423
  # Again check the span attributes, in case we relied on another sampler
392
424
  if span.attributes:
393
425
  if url_path := span.attributes.get(url_attributes.URL_PATH, ""):
@@ -409,10 +441,15 @@ class ObserverSpanProcessor(SpanProcessor):
409
441
  if not (context := parent_context or context_api.get_current()):
410
442
  return None
411
443
 
412
- if not (cookies := baggage.get_baggage("http.request.cookies", context)):
444
+ cookies = cast(
445
+ MutableMapping[str, str] | None,
446
+ baggage.get_baggage("http.request.cookies", context),
447
+ )
448
+ if not cookies:
413
449
  return None
414
450
 
415
- if not (observer_cookie := cookies.get(Observer.COOKIE_NAME)):
451
+ observer_cookie = cookies.get(Observer.COOKIE_NAME)
452
+ if not observer_cookie:
416
453
  return None
417
454
 
418
455
  try:
@@ -426,11 +463,11 @@ class ObserverSpanProcessor(SpanProcessor):
426
463
 
427
464
  return None
428
465
 
429
- def shutdown(self):
466
+ def shutdown(self) -> None:
430
467
  """Cleanup when shutting down."""
431
468
  with self._traces_lock:
432
469
  self._traces.clear()
433
470
 
434
- def force_flush(self, timeout_millis=None):
471
+ def force_flush(self, timeout_millis: int | None = None) -> bool:
435
472
  """Required by SpanProcessor interface."""
436
473
  return True
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  from functools import cached_property
4
+ from typing import Any
2
5
 
3
6
  from plain.toolbar import ToolbarItem, register_toolbar_item
4
7
 
@@ -12,11 +15,11 @@ class ObserverToolbarItem(ToolbarItem):
12
15
  button_template_name = "toolbar/observer_button.html"
13
16
 
14
17
  @cached_property
15
- def observer(self):
18
+ def observer(self) -> Observer:
16
19
  """Get the Observer instance for this request."""
17
20
  return Observer(self.request)
18
21
 
19
- def get_template_context(self):
22
+ def get_template_context(self) -> dict[str, Any]:
20
23
  context = super().get_template_context()
21
24
  context["observer"] = self.observer
22
25
  return context
@@ -1,3 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from plain import models
1
6
  from plain.auth.views import AuthViewMixin
2
7
  from plain.htmx.views import HTMXViewMixin
3
8
  from plain.http import JsonResponse, Response
@@ -14,28 +19,28 @@ class ObserverTracesView(AuthViewMixin, HTMXViewMixin, ListView):
14
19
  context_object_name = "traces"
15
20
  admin_required = True
16
21
 
17
- def get_objects(self):
22
+ def get_objects(self) -> models.QuerySet:
18
23
  return Trace.query.all()
19
24
 
20
- def check_auth(self):
25
+ def check_auth(self) -> None:
21
26
  # Allow the view if we're in DEBUG
22
27
  if settings.DEBUG:
23
28
  return
24
29
 
25
30
  super().check_auth()
26
31
 
27
- def get_response(self):
32
+ def get_response(self) -> Response:
28
33
  response = super().get_response()
29
34
  # So we can load it in the toolbar
30
35
  response.headers["X-Frame-Options"] = "SAMEORIGIN"
31
36
  return response
32
37
 
33
- def get_template_context(self):
38
+ def get_template_context(self) -> dict[str, Any]:
34
39
  context = super().get_template_context()
35
40
  context["observer"] = Observer(self.request)
36
41
  return context
37
42
 
38
- def htmx_put_mode(self):
43
+ def htmx_put_mode(self) -> Response:
39
44
  """Set observer mode via HTMX PUT."""
40
45
  mode = self.request.data.get("mode")
41
46
  observer = Observer(self.request)
@@ -54,14 +59,14 @@ class ObserverTracesView(AuthViewMixin, HTMXViewMixin, ListView):
54
59
 
55
60
  return response
56
61
 
57
- def htmx_delete_traces(self):
62
+ def htmx_delete_traces(self) -> Response:
58
63
  """Clear all traces via HTMX DELETE."""
59
64
  Trace.query.filter(share_id="").delete()
60
65
  response = Response(status_code=204)
61
66
  response.headers["HX-Refresh"] = "true"
62
67
  return response
63
68
 
64
- def post(self):
69
+ def post(self) -> Response:
65
70
  """Handle POST requests to set observer mode."""
66
71
  action = self.request.data.get("observe_action")
67
72
  if action == "summary":
@@ -79,19 +84,20 @@ class ObserverTraceDetailView(AuthViewMixin, HTMXViewMixin, DetailView):
79
84
  context_object_name = "trace"
80
85
  admin_required = True
81
86
 
82
- def get_object(self):
87
+ def get_object(self) -> Trace | None:
83
88
  return Trace.query.get_or_none(trace_id=self.url_kwargs.get("trace_id"))
84
89
 
85
- def check_auth(self):
90
+ def check_auth(self) -> None:
86
91
  # Allow the view if we're in DEBUG
87
92
  if settings.DEBUG:
88
93
  return
89
94
  super().check_auth()
90
95
 
91
- def get(self):
96
+ def get(self) -> Response | dict[str, Any]:
92
97
  """Return trace data as HTML, JSON, or logs based on content negotiation."""
98
+ preferred = self.request.get_preferred_type("application/json", "text/html")
93
99
  if (
94
- "application/json" in self.request.headers.get("Accept", "")
100
+ preferred == "application/json"
95
101
  or self.request.query_params.get("format") == "json"
96
102
  ):
97
103
  return self.object.as_dict()
@@ -107,13 +113,13 @@ class ObserverTraceDetailView(AuthViewMixin, HTMXViewMixin, DetailView):
107
113
 
108
114
  return super().get()
109
115
 
110
- def get_template_names(self):
116
+ def get_template_names(self) -> list[str]:
111
117
  if self.is_htmx_request():
112
118
  # Use a different template for HTMX requests
113
119
  return ["observer/trace.html"]
114
120
  return super().get_template_names()
115
121
 
116
- def htmx_delete(self):
122
+ def htmx_delete(self) -> Response:
117
123
  self.object.delete()
118
124
 
119
125
  # Redirect to traces list after deletion
@@ -121,11 +127,11 @@ class ObserverTraceDetailView(AuthViewMixin, HTMXViewMixin, DetailView):
121
127
  response.headers["HX-Redirect"] = reverse("observer:traces")
122
128
  return response
123
129
 
124
- def htmx_post_share(self):
130
+ def htmx_post_share(self) -> Response:
125
131
  self.object.generate_share_id()
126
132
  return super().get()
127
133
 
128
- def htmx_delete_share(self):
134
+ def htmx_delete_share(self) -> Response:
129
135
  self.object.remove_share_id()
130
136
  return super().get()
131
137
 
@@ -136,18 +142,19 @@ class ObserverTraceSharedView(DetailView):
136
142
  template_name = "observer/trace_share.html"
137
143
  context_object_name = "trace"
138
144
 
139
- def get_object(self):
145
+ def get_object(self) -> Trace | None:
140
146
  return Trace.query.get_or_none(share_id=self.url_kwargs["share_id"])
141
147
 
142
- def get_template_context(self):
148
+ def get_template_context(self) -> dict[str, Any]:
143
149
  context = super().get_template_context()
144
150
  context["is_share_view"] = True
145
151
  return context
146
152
 
147
- def get(self):
153
+ def get(self) -> Response:
148
154
  """Return trace data as HTML or JSON based on content negotiation."""
155
+ preferred = self.request.get_preferred_type("application/json", "text/html")
149
156
  if (
150
- "application/json" in self.request.headers.get("Accept", "")
157
+ preferred == "application/json"
151
158
  or self.request.query_params.get("format") == "json"
152
159
  ):
153
160
  return JsonResponse(self.object.as_dict())
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.observer"
3
- version = "0.8.0"
3
+ version = "0.9.1"
4
4
  description = "On-page telemetry and observability tools for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
File without changes
File without changes