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.
- {plain_observer-0.8.0 → plain_observer-0.9.1}/.gitignore +1 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/PKG-INFO +1 -1
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/CHANGELOG.md +22 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/admin.py +11 -6
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/cli.py +37 -19
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/config.py +9 -5
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/core.py +14 -10
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/logging.py +15 -5
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/models.py +66 -46
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/otel.py +85 -48
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/toolbar.py +5 -2
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/views.py +26 -19
- {plain_observer-0.8.0 → plain_observer-0.9.1}/pyproject.toml +1 -1
- {plain_observer-0.8.0 → plain_observer-0.9.1}/LICENSE +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/README.md +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/README.md +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/__init__.py +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/default_settings.py +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0001_initial.py +0 -0
- {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
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +0 -0
- {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
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0006_remove_log_logger.py +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/migrations/__init__.py +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/log.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/span.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_detail.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_share.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/traces.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer_button.html +0 -0
- {plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/urls.py +0 -0
|
@@ -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:
|
|
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
|
|
|
@@ -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.
|
|
40
|
-
service_attributes.SERVICE_VERSION: settings.
|
|
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 = {}
|
|
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
|
|
186
|
-
app_version=app_version or
|
|
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(
|
|
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
|
|
459
|
-
|
|
460
|
-
elif
|
|
461
|
-
|
|
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(
|
|
481
|
+
return datetime.fromtimestamp(ts_value, tz=UTC)
|
|
464
482
|
except (ValueError, OSError):
|
|
465
|
-
return str(
|
|
466
|
-
|
|
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.
|
|
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,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"
|
|
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"
|
|
157
|
+
preferred == "application/json"
|
|
151
158
|
or self.request.query_params.get("format") == "json"
|
|
152
159
|
):
|
|
153
160
|
return JsonResponse(self.object.as_dict())
|
|
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.8.0 → plain_observer-0.9.1}/plain/observer/migrations/0006_remove_log_logger.py
RENAMED
|
File without changes
|
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/log.html
RENAMED
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/partials/span.html
RENAMED
|
File without changes
|
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_detail.html
RENAMED
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/observer/trace_share.html
RENAMED
|
File without changes
|
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer.html
RENAMED
|
File without changes
|
{plain_observer-0.8.0 → plain_observer-0.9.1}/plain/observer/templates/toolbar/observer_button.html
RENAMED
|
File without changes
|
|
File without changes
|