plain.observer 0.9.0__tar.gz → 0.10.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.9.0 → plain_observer-0.10.0}/.gitignore +1 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/PKG-INFO +1 -1
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/CHANGELOG.md +23 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/admin.py +11 -6
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/cli.py +37 -19
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/config.py +7 -3
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/core.py +14 -10
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/logging.py +15 -5
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/models.py +86 -62
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/otel.py +85 -48
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/toolbar.py +5 -2
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/views.py +22 -17
- {plain_observer-0.9.0 → plain_observer-0.10.0}/pyproject.toml +1 -1
- {plain_observer-0.9.0 → plain_observer-0.10.0}/LICENSE +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/README.md +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/README.md +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/__init__.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/default_settings.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0001_initial.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0006_remove_log_logger.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/__init__.py +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/partials/log.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/partials/span.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/trace.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/trace_detail.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/trace_share.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/traces.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/toolbar/observer.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/toolbar/observer_button.html +0 -0
- {plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/urls.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# plain-observer changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/dropseed/plain/releases/plain-observer@0.10.0) (2025-10-07)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Model configuration now uses `model_options` descriptor instead of `class Meta` for improved consistency with Plain framework patterns ([17a378d](https://github.com/dropseed/plain/commit/17a378dcfb))
|
|
8
|
+
- Custom QuerySet classes are now defined as descriptors on the model class instead of being configured in Meta ([2578301](https://github.com/dropseed/plain/commit/2578301819))
|
|
9
|
+
- Internal model metadata split into separate `model_options` and `_model_meta` attributes for better organization ([73ba469](https://github.com/dropseed/plain/commit/73ba469ba0))
|
|
10
|
+
|
|
11
|
+
### Upgrade instructions
|
|
12
|
+
|
|
13
|
+
- No changes required
|
|
14
|
+
|
|
15
|
+
## [0.9.1](https://github.com/dropseed/plain/releases/plain-observer@0.9.1) (2025-10-06)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- Added comprehensive type annotations throughout the package for improved IDE support and type checking ([ffb8624](https://github.com/dropseed/plain/commit/ffb8624d6f))
|
|
20
|
+
- Package has been validated with 100% type coverage and added to the type validation script ([ffb8624](https://github.com/dropseed/plain/commit/ffb8624d6f))
|
|
21
|
+
|
|
22
|
+
### Upgrade instructions
|
|
23
|
+
|
|
24
|
+
- No changes required
|
|
25
|
+
|
|
3
26
|
## [0.9.0](https://github.com/dropseed/plain/releases/plain-observer@0.9.0) (2025-09-30)
|
|
4
27
|
|
|
5
28
|
### 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:
|
|
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:
|
|
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:
|
|
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(
|
|
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}} {
|
|
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}} {
|
|
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
|
-
|
|
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(
|
|
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
|
|
|
@@ -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 = {}
|
|
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,48 +57,55 @@ 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
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from plain.models.fields.related_managers import BaseRelatedManager
|
|
62
|
+
|
|
63
|
+
spans: BaseRelatedManager
|
|
64
|
+
logs: BaseRelatedManager
|
|
65
|
+
|
|
66
|
+
model_options = models.Options(
|
|
67
|
+
ordering=["-start_time"],
|
|
68
|
+
constraints=[
|
|
58
69
|
models.UniqueConstraint(
|
|
59
70
|
fields=["trace_id"],
|
|
60
71
|
name="observer_unique_trace_id",
|
|
61
72
|
)
|
|
62
|
-
]
|
|
63
|
-
indexes
|
|
73
|
+
],
|
|
74
|
+
indexes=[
|
|
64
75
|
models.Index(fields=["trace_id"]),
|
|
65
76
|
models.Index(fields=["start_time"]),
|
|
66
77
|
models.Index(fields=["request_id"]),
|
|
67
78
|
models.Index(fields=["share_id"]),
|
|
68
79
|
models.Index(fields=["session_id"]),
|
|
69
|
-
]
|
|
80
|
+
],
|
|
81
|
+
)
|
|
70
82
|
|
|
71
|
-
def __str__(self):
|
|
83
|
+
def __str__(self) -> str:
|
|
72
84
|
return self.trace_id
|
|
73
85
|
|
|
74
|
-
def get_absolute_url(self):
|
|
86
|
+
def get_absolute_url(self) -> str:
|
|
75
87
|
"""Return the canonical URL for this trace."""
|
|
76
88
|
return reverse("observer:trace_detail", trace_id=self.trace_id)
|
|
77
89
|
|
|
78
|
-
def generate_share_id(self):
|
|
90
|
+
def generate_share_id(self) -> str:
|
|
79
91
|
"""Generate a unique share ID for this trace."""
|
|
80
92
|
self.share_id = secrets.token_urlsafe(24)
|
|
81
93
|
self.share_created_at = timezone.now()
|
|
82
94
|
self.save(update_fields=["share_id", "share_created_at"])
|
|
83
95
|
return self.share_id
|
|
84
96
|
|
|
85
|
-
def remove_share_id(self):
|
|
97
|
+
def remove_share_id(self) -> None:
|
|
86
98
|
"""Remove the share ID from this trace."""
|
|
87
99
|
self.share_id = ""
|
|
88
100
|
self.share_created_at = None
|
|
89
101
|
self.save(update_fields=["share_id", "share_created_at"])
|
|
90
102
|
|
|
91
|
-
def duration_ms(self):
|
|
103
|
+
def duration_ms(self) -> float:
|
|
92
104
|
return (self.end_time - self.start_time).total_seconds() * 1000
|
|
93
105
|
|
|
94
|
-
def get_trace_summary(self, spans):
|
|
106
|
+
def get_trace_summary(self, spans: Iterable[Span]) -> str:
|
|
95
107
|
# Count database queries with query text and track duplicates
|
|
96
|
-
query_texts = []
|
|
108
|
+
query_texts: list[str] = []
|
|
97
109
|
for span in spans:
|
|
98
110
|
if query_text := span.attributes.get(db_attributes.DB_QUERY_TEXT):
|
|
99
111
|
query_texts.append(query_text)
|
|
@@ -103,7 +115,7 @@ class Trace(models.Model):
|
|
|
103
115
|
duplicate_count = sum(query_counts.values()) - len(query_counts)
|
|
104
116
|
|
|
105
117
|
# Build summary: "n spans, n queries (n duplicates), Xms"
|
|
106
|
-
parts = []
|
|
118
|
+
parts: list[str] = []
|
|
107
119
|
|
|
108
120
|
# Queries count with duplicates
|
|
109
121
|
if query_total > 0:
|
|
@@ -119,7 +131,7 @@ class Trace(models.Model):
|
|
|
119
131
|
return " • ".join(parts)
|
|
120
132
|
|
|
121
133
|
@classmethod
|
|
122
|
-
def from_opentelemetry_spans(cls, spans):
|
|
134
|
+
def from_opentelemetry_spans(cls, spans: Sequence[ReadableSpan]) -> Trace:
|
|
123
135
|
"""Create a Trace instance from a list of OpenTelemetry spans."""
|
|
124
136
|
# Get trace information from the first span
|
|
125
137
|
first_span = spans[0]
|
|
@@ -187,7 +199,7 @@ class Trace(models.Model):
|
|
|
187
199
|
root_span_name=root_span.name if root_span else "",
|
|
188
200
|
)
|
|
189
201
|
|
|
190
|
-
def as_dict(self):
|
|
202
|
+
def as_dict(self) -> dict[str, Any]:
|
|
191
203
|
spans = [
|
|
192
204
|
span.span_data for span in self.spans.query.all().order_by("start_time")
|
|
193
205
|
]
|
|
@@ -217,9 +229,9 @@ class Trace(models.Model):
|
|
|
217
229
|
"logs": logs,
|
|
218
230
|
}
|
|
219
231
|
|
|
220
|
-
def get_timeline_events(self):
|
|
232
|
+
def get_timeline_events(self) -> list[dict[str, Any]]:
|
|
221
233
|
"""Get chronological list of spans and logs for unified timeline display."""
|
|
222
|
-
events = []
|
|
234
|
+
events: list[dict[str, Any]] = []
|
|
223
235
|
|
|
224
236
|
for span in self.spans.query.all().annotate_spans():
|
|
225
237
|
events.append(
|
|
@@ -257,13 +269,13 @@ class Trace(models.Model):
|
|
|
257
269
|
return sorted(events, key=lambda x: x["timestamp"])
|
|
258
270
|
|
|
259
271
|
|
|
260
|
-
class SpanQuerySet(models.QuerySet):
|
|
261
|
-
def annotate_spans(self):
|
|
272
|
+
class SpanQuerySet(models.QuerySet["Span"]):
|
|
273
|
+
def annotate_spans(self) -> list[Span]:
|
|
262
274
|
"""Annotate spans with nesting levels and duplicate query warnings."""
|
|
263
|
-
spans = list(self.order_by("start_time"))
|
|
275
|
+
spans: list[Span] = list(self.order_by("start_time"))
|
|
264
276
|
|
|
265
277
|
# Build span dictionary for parent lookups
|
|
266
|
-
span_dict = {span.span_id: span for span in spans}
|
|
278
|
+
span_dict: dict[str, Span] = {span.span_id: span for span in spans}
|
|
267
279
|
|
|
268
280
|
# Calculate nesting levels
|
|
269
281
|
for span in spans:
|
|
@@ -275,7 +287,7 @@ class SpanQuerySet(models.QuerySet):
|
|
|
275
287
|
parent_level = parent.level if parent else 0
|
|
276
288
|
span.level = parent_level + 1
|
|
277
289
|
|
|
278
|
-
query_counts = {}
|
|
290
|
+
query_counts: dict[str, int] = {}
|
|
279
291
|
|
|
280
292
|
# First pass: count queries
|
|
281
293
|
for span in spans:
|
|
@@ -283,7 +295,7 @@ class SpanQuerySet(models.QuerySet):
|
|
|
283
295
|
query_counts[sql_query] = query_counts.get(sql_query, 0) + 1
|
|
284
296
|
|
|
285
297
|
# Second pass: add annotations
|
|
286
|
-
query_occurrences = {}
|
|
298
|
+
query_occurrences: dict[str, int] = {}
|
|
287
299
|
for span in spans:
|
|
288
300
|
span.annotations = []
|
|
289
301
|
|
|
@@ -318,24 +330,30 @@ class Span(models.Model):
|
|
|
318
330
|
status = models.CharField(max_length=50, default="", required=False)
|
|
319
331
|
span_data = models.JSONField(default=dict, required=False)
|
|
320
332
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
333
|
+
query = SpanQuerySet()
|
|
334
|
+
|
|
335
|
+
model_options = models.Options(
|
|
336
|
+
ordering=["-start_time"],
|
|
337
|
+
constraints=[
|
|
325
338
|
models.UniqueConstraint(
|
|
326
339
|
fields=["trace", "span_id"],
|
|
327
340
|
name="observer_unique_span_id",
|
|
328
341
|
)
|
|
329
|
-
]
|
|
330
|
-
indexes
|
|
342
|
+
],
|
|
343
|
+
indexes=[
|
|
331
344
|
models.Index(fields=["span_id"]),
|
|
332
345
|
models.Index(fields=["trace", "span_id"]),
|
|
333
346
|
models.Index(fields=["trace"]),
|
|
334
347
|
models.Index(fields=["start_time"]),
|
|
335
|
-
]
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if TYPE_CHECKING:
|
|
352
|
+
level: int
|
|
353
|
+
annotations: list[dict[str, Any]]
|
|
336
354
|
|
|
337
355
|
@classmethod
|
|
338
|
-
def from_opentelemetry_span(cls, otel_span, trace):
|
|
356
|
+
def from_opentelemetry_span(cls, otel_span: ReadableSpan, trace: Trace) -> Span:
|
|
339
357
|
"""Create a Span instance from an OpenTelemetry span."""
|
|
340
358
|
|
|
341
359
|
span_data = json.loads(otel_span.to_json())
|
|
@@ -357,51 +375,51 @@ class Span(models.Model):
|
|
|
357
375
|
span_data=span_data,
|
|
358
376
|
)
|
|
359
377
|
|
|
360
|
-
def __str__(self):
|
|
378
|
+
def __str__(self) -> str:
|
|
361
379
|
return self.span_id
|
|
362
380
|
|
|
363
381
|
@property
|
|
364
|
-
def attributes(self):
|
|
382
|
+
def attributes(self) -> Mapping[str, Any]:
|
|
365
383
|
"""Get attributes from span_data."""
|
|
366
|
-
return self.span_data.get("attributes", {})
|
|
384
|
+
return cast(Mapping[str, Any], self.span_data.get("attributes", {}))
|
|
367
385
|
|
|
368
386
|
@property
|
|
369
|
-
def events(self):
|
|
387
|
+
def events(self) -> list[Mapping[str, Any]]:
|
|
370
388
|
"""Get events from span_data."""
|
|
371
|
-
return self.span_data.get("events", [])
|
|
389
|
+
return cast(list[Mapping[str, Any]], self.span_data.get("events", []))
|
|
372
390
|
|
|
373
391
|
@property
|
|
374
|
-
def links(self):
|
|
392
|
+
def links(self) -> list[Mapping[str, Any]]:
|
|
375
393
|
"""Get links from span_data."""
|
|
376
|
-
return self.span_data.get("links", [])
|
|
394
|
+
return cast(list[Mapping[str, Any]], self.span_data.get("links", []))
|
|
377
395
|
|
|
378
396
|
@property
|
|
379
|
-
def resource(self):
|
|
397
|
+
def resource(self) -> Mapping[str, Any]:
|
|
380
398
|
"""Get resource from span_data."""
|
|
381
|
-
return self.span_data.get("resource", {})
|
|
399
|
+
return cast(Mapping[str, Any], self.span_data.get("resource", {}))
|
|
382
400
|
|
|
383
401
|
@property
|
|
384
|
-
def context(self):
|
|
402
|
+
def context(self) -> Mapping[str, Any]:
|
|
385
403
|
"""Get context from span_data."""
|
|
386
|
-
return self.span_data.get("context", {})
|
|
404
|
+
return cast(Mapping[str, Any], self.span_data.get("context", {}))
|
|
387
405
|
|
|
388
|
-
def duration_ms(self):
|
|
406
|
+
def duration_ms(self) -> float:
|
|
389
407
|
if self.start_time and self.end_time:
|
|
390
408
|
return (self.end_time - self.start_time).total_seconds() * 1000
|
|
391
409
|
return 0
|
|
392
410
|
|
|
393
411
|
@cached_property
|
|
394
|
-
def sql_query(self):
|
|
412
|
+
def sql_query(self) -> str | None:
|
|
395
413
|
"""Get the SQL query if this span contains one."""
|
|
396
414
|
return self.attributes.get(db_attributes.DB_QUERY_TEXT)
|
|
397
415
|
|
|
398
416
|
@cached_property
|
|
399
|
-
def sql_query_params(self):
|
|
417
|
+
def sql_query_params(self) -> dict[str, Any]:
|
|
400
418
|
"""Get query parameters from attributes that start with 'db.query.parameter.'"""
|
|
401
419
|
if not self.attributes:
|
|
402
420
|
return {}
|
|
403
421
|
|
|
404
|
-
query_params = {}
|
|
422
|
+
query_params: dict[str, Any] = {}
|
|
405
423
|
for key, value in self.attributes.items():
|
|
406
424
|
if key.startswith(DB_QUERY_PARAMETER_TEMPLATE + "."):
|
|
407
425
|
param_name = key.replace(DB_QUERY_PARAMETER_TEMPLATE + ".", "")
|
|
@@ -410,7 +428,7 @@ class Span(models.Model):
|
|
|
410
428
|
return query_params
|
|
411
429
|
|
|
412
430
|
@cached_property
|
|
413
|
-
def source_code_location(self):
|
|
431
|
+
def source_code_location(self) -> dict[str, Any] | None:
|
|
414
432
|
"""Get the source code location attributes from this span."""
|
|
415
433
|
if not self.attributes:
|
|
416
434
|
return None
|
|
@@ -432,7 +450,7 @@ class Span(models.Model):
|
|
|
432
450
|
|
|
433
451
|
return code_attrs if code_attrs else None
|
|
434
452
|
|
|
435
|
-
def get_formatted_sql(self):
|
|
453
|
+
def get_formatted_sql(self) -> str | None:
|
|
436
454
|
"""Get the pretty-formatted SQL query if this span contains one."""
|
|
437
455
|
sql = self.sql_query
|
|
438
456
|
if not sql:
|
|
@@ -450,22 +468,27 @@ class Span(models.Model):
|
|
|
450
468
|
comma_first=False,
|
|
451
469
|
)
|
|
452
470
|
|
|
453
|
-
def format_event_timestamp(
|
|
471
|
+
def format_event_timestamp(
|
|
472
|
+
self, timestamp: float | int | datetime | str
|
|
473
|
+
) -> datetime | str:
|
|
454
474
|
"""Convert event timestamp to a readable datetime."""
|
|
455
475
|
if isinstance(timestamp, int | float):
|
|
476
|
+
ts_value = float(timestamp)
|
|
456
477
|
try:
|
|
457
478
|
# Try as seconds first
|
|
458
|
-
if
|
|
459
|
-
|
|
460
|
-
elif
|
|
461
|
-
|
|
479
|
+
if ts_value > 1e10: # Likely nanoseconds
|
|
480
|
+
ts_value /= 1e9
|
|
481
|
+
elif ts_value > 1e7: # Likely milliseconds
|
|
482
|
+
ts_value /= 1e3
|
|
462
483
|
|
|
463
|
-
return datetime.fromtimestamp(
|
|
484
|
+
return datetime.fromtimestamp(ts_value, tz=UTC)
|
|
464
485
|
except (ValueError, OSError):
|
|
465
|
-
return str(
|
|
466
|
-
|
|
486
|
+
return str(ts_value)
|
|
487
|
+
if isinstance(timestamp, datetime):
|
|
488
|
+
return timestamp
|
|
489
|
+
return str(timestamp)
|
|
467
490
|
|
|
468
|
-
def get_exception_stacktrace(self):
|
|
491
|
+
def get_exception_stacktrace(self) -> str | None:
|
|
469
492
|
"""Get the exception stacktrace if this span has an exception event."""
|
|
470
493
|
if not self.events:
|
|
471
494
|
return None
|
|
@@ -493,11 +516,12 @@ class Log(models.Model):
|
|
|
493
516
|
level = models.CharField(max_length=20)
|
|
494
517
|
message = models.TextField()
|
|
495
518
|
|
|
496
|
-
|
|
497
|
-
ordering
|
|
498
|
-
indexes
|
|
519
|
+
model_options = models.Options(
|
|
520
|
+
ordering=["timestamp"],
|
|
521
|
+
indexes=[
|
|
499
522
|
models.Index(fields=["trace", "timestamp"]),
|
|
500
523
|
models.Index(fields=["trace", "span"]),
|
|
501
524
|
models.Index(fields=["timestamp"]),
|
|
502
525
|
models.Index(fields=["trace"]),
|
|
503
|
-
]
|
|
526
|
+
],
|
|
527
|
+
)
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,16 +84,16 @@ 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."""
|
|
93
98
|
preferred = self.request.get_preferred_type("application/json", "text/html")
|
|
94
99
|
if (
|
|
@@ -108,13 +113,13 @@ class ObserverTraceDetailView(AuthViewMixin, HTMXViewMixin, DetailView):
|
|
|
108
113
|
|
|
109
114
|
return super().get()
|
|
110
115
|
|
|
111
|
-
def get_template_names(self):
|
|
116
|
+
def get_template_names(self) -> list[str]:
|
|
112
117
|
if self.is_htmx_request():
|
|
113
118
|
# Use a different template for HTMX requests
|
|
114
119
|
return ["observer/trace.html"]
|
|
115
120
|
return super().get_template_names()
|
|
116
121
|
|
|
117
|
-
def htmx_delete(self):
|
|
122
|
+
def htmx_delete(self) -> Response:
|
|
118
123
|
self.object.delete()
|
|
119
124
|
|
|
120
125
|
# Redirect to traces list after deletion
|
|
@@ -122,11 +127,11 @@ class ObserverTraceDetailView(AuthViewMixin, HTMXViewMixin, DetailView):
|
|
|
122
127
|
response.headers["HX-Redirect"] = reverse("observer:traces")
|
|
123
128
|
return response
|
|
124
129
|
|
|
125
|
-
def htmx_post_share(self):
|
|
130
|
+
def htmx_post_share(self) -> Response:
|
|
126
131
|
self.object.generate_share_id()
|
|
127
132
|
return super().get()
|
|
128
133
|
|
|
129
|
-
def htmx_delete_share(self):
|
|
134
|
+
def htmx_delete_share(self) -> Response:
|
|
130
135
|
self.object.remove_share_id()
|
|
131
136
|
return super().get()
|
|
132
137
|
|
|
@@ -137,15 +142,15 @@ class ObserverTraceSharedView(DetailView):
|
|
|
137
142
|
template_name = "observer/trace_share.html"
|
|
138
143
|
context_object_name = "trace"
|
|
139
144
|
|
|
140
|
-
def get_object(self):
|
|
145
|
+
def get_object(self) -> Trace | None:
|
|
141
146
|
return Trace.query.get_or_none(share_id=self.url_kwargs["share_id"])
|
|
142
147
|
|
|
143
|
-
def get_template_context(self):
|
|
148
|
+
def get_template_context(self) -> dict[str, Any]:
|
|
144
149
|
context = super().get_template_context()
|
|
145
150
|
context["is_share_view"] = True
|
|
146
151
|
return context
|
|
147
152
|
|
|
148
|
-
def get(self):
|
|
153
|
+
def get(self) -> Response:
|
|
149
154
|
"""Return trace data as HTML or JSON based on content negotiation."""
|
|
150
155
|
preferred = self.request.get_preferred_type("application/json", "text/html")
|
|
151
156
|
if (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/migrations/0006_remove_log_logger.py
RENAMED
|
File without changes
|
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/partials/log.html
RENAMED
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/partials/span.html
RENAMED
|
File without changes
|
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/trace_detail.html
RENAMED
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/trace_share.html
RENAMED
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/observer/traces.html
RENAMED
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/toolbar/observer.html
RENAMED
|
File without changes
|
{plain_observer-0.9.0 → plain_observer-0.10.0}/plain/observer/templates/toolbar/observer_button.html
RENAMED
|
File without changes
|
|
File without changes
|