plain.observer 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of plain.observer might be problematic. Click here for more details.
- plain/observer/CHANGELOG.md +18 -0
- plain/observer/admin.py +46 -1
- plain/observer/config.py +16 -1
- plain/observer/logging.py +74 -0
- plain/observer/migrations/0004_trace_app_name_trace_app_version.py +23 -0
- plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +67 -0
- plain/observer/migrations/0006_remove_log_logger.py +16 -0
- plain/observer/models.py +147 -19
- plain/observer/otel.py +53 -16
- plain/observer/templates/observer/partials/log.html +19 -0
- plain/observer/templates/observer/partials/span.html +299 -0
- plain/observer/templates/observer/trace.html +124 -352
- plain/observer/templates/observer/traces.html +50 -28
- plain/observer/views.py +15 -9
- {plain_observer-0.5.0.dist-info → plain_observer-0.6.0.dist-info}/METADATA +2 -2
- plain_observer-0.6.0.dist-info/RECORD +33 -0
- plain_observer-0.5.0.dist-info/RECORD +0 -27
- {plain_observer-0.5.0.dist-info → plain_observer-0.6.0.dist-info}/WHEEL +0 -0
- {plain_observer-0.5.0.dist-info → plain_observer-0.6.0.dist-info}/licenses/LICENSE +0 -0
plain/observer/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# plain-observer changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0](https://github.com/dropseed/plain/releases/plain-observer@0.6.0) (2025-09-09)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Added comprehensive log capture and display during trace recording, with logs shown in a unified timeline alongside spans ([9bfe938](https://github.com/dropseed/plain/commit/9bfe938f64))
|
|
8
|
+
- Added new Log model with admin interface for managing captured log entries ([9bfe938](https://github.com/dropseed/plain/commit/9bfe938f64))
|
|
9
|
+
- Observer now automatically enables debug logging during trace recording to capture more detailed information ([731196](https://github.com/dropseed/plain/commit/731196086f))
|
|
10
|
+
- Added app_name and app_version fields to trace records for better application identification ([2870636](https://github.com/dropseed/plain/commit/2870636944))
|
|
11
|
+
- Added span count display in trace detail views ([4d22c10](https://github.com/dropseed/plain/commit/4d22c1058d))
|
|
12
|
+
- Enhanced database query counting to only include queries with actual query text, providing more accurate metrics ([3d102d3](https://github.com/dropseed/plain/commit/3d102d3796))
|
|
13
|
+
- Improved trace limit cleanup logic to properly maintain the configured trace limit ([e9d124b](https://github.com/dropseed/plain/commit/e9d124bccd))
|
|
14
|
+
- Added source code location attributes support for spans with file path, line number, and function information ([da36a17](https://github.com/dropseed/plain/commit/da36a17dab))
|
|
15
|
+
- Updated Python version requirement to 3.13 minimum ([d86e307](https://github.com/dropseed/plain/commit/d86e307efb))
|
|
16
|
+
|
|
17
|
+
### Upgrade instructions
|
|
18
|
+
|
|
19
|
+
- No changes required
|
|
20
|
+
|
|
3
21
|
## [0.5.0](https://github.com/dropseed/plain/releases/plain-observer@0.5.0) (2025-09-03)
|
|
4
22
|
|
|
5
23
|
### What's changed
|
plain/observer/admin.py
CHANGED
|
@@ -5,7 +5,7 @@ from plain.admin.views import (
|
|
|
5
5
|
register_viewset,
|
|
6
6
|
)
|
|
7
7
|
|
|
8
|
-
from .models import Span, Trace
|
|
8
|
+
from .models import Log, Span, Trace
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@register_viewset
|
|
@@ -77,3 +77,48 @@ class SpanViewset(AdminViewset):
|
|
|
77
77
|
|
|
78
78
|
class DetailView(AdminModelDetailView):
|
|
79
79
|
model = Span
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@register_viewset
|
|
83
|
+
class LogViewset(AdminViewset):
|
|
84
|
+
class ListView(AdminModelListView):
|
|
85
|
+
nav_section = "Observer"
|
|
86
|
+
nav_icon = "activity"
|
|
87
|
+
model = Log
|
|
88
|
+
fields = [
|
|
89
|
+
"timestamp",
|
|
90
|
+
"level",
|
|
91
|
+
"logger",
|
|
92
|
+
"message",
|
|
93
|
+
"trace",
|
|
94
|
+
"span",
|
|
95
|
+
]
|
|
96
|
+
queryset_order = ["-timestamp"]
|
|
97
|
+
allow_global_search = False
|
|
98
|
+
search_fields = ["logger", "message", "level"]
|
|
99
|
+
filters = ["level", "logger"]
|
|
100
|
+
actions = ["Delete selected", "Delete all"]
|
|
101
|
+
|
|
102
|
+
def perform_action(self, action: str, target_ids: list):
|
|
103
|
+
if action == "Delete selected":
|
|
104
|
+
Log.objects.filter(id__in=target_ids).delete()
|
|
105
|
+
elif action == "Delete all":
|
|
106
|
+
Log.objects.all().delete()
|
|
107
|
+
|
|
108
|
+
def get_objects(self):
|
|
109
|
+
return (
|
|
110
|
+
super()
|
|
111
|
+
.get_objects()
|
|
112
|
+
.select_related("trace", "span")
|
|
113
|
+
.only(
|
|
114
|
+
"timestamp",
|
|
115
|
+
"level",
|
|
116
|
+
"logger",
|
|
117
|
+
"message",
|
|
118
|
+
"span__span_id",
|
|
119
|
+
"trace__trace_id",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
class DetailView(AdminModelDetailView):
|
|
124
|
+
model = Log
|
plain/observer/config.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
from opentelemetry import trace
|
|
2
|
+
from opentelemetry.sdk.resources import Resource
|
|
2
3
|
from opentelemetry.sdk.trace import TracerProvider
|
|
4
|
+
from opentelemetry.semconv.attributes import service_attributes
|
|
3
5
|
|
|
6
|
+
from plain.logs import app_logger
|
|
4
7
|
from plain.packages import PackageConfig, register_config
|
|
8
|
+
from plain.runtime import settings
|
|
5
9
|
|
|
10
|
+
from .logging import observer_log_handler
|
|
6
11
|
from .otel import (
|
|
7
12
|
ObserverCombinedSampler,
|
|
8
13
|
ObserverSampler,
|
|
@@ -29,10 +34,20 @@ class Config(PackageConfig):
|
|
|
29
34
|
provider.add_span_processor(span_processor)
|
|
30
35
|
else:
|
|
31
36
|
# Start our own provider, new sampler, and span processor
|
|
32
|
-
|
|
37
|
+
resource = Resource.create(
|
|
38
|
+
{
|
|
39
|
+
service_attributes.SERVICE_NAME: settings.APP_NAME,
|
|
40
|
+
service_attributes.SERVICE_VERSION: settings.APP_VERSION,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
provider = TracerProvider(sampler=sampler, resource=resource)
|
|
33
44
|
provider.add_span_processor(span_processor)
|
|
34
45
|
trace.set_tracer_provider(provider)
|
|
35
46
|
|
|
47
|
+
# Install the logging handler to capture logs during traces
|
|
48
|
+
if observer_log_handler not in app_logger.handlers:
|
|
49
|
+
app_logger.addHandler(observer_log_handler)
|
|
50
|
+
|
|
36
51
|
@staticmethod
|
|
37
52
|
def get_existing_trace_provider():
|
|
38
53
|
"""Return the currently configured provider if set."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from opentelemetry import trace
|
|
6
|
+
from opentelemetry.trace import format_span_id, format_trace_id
|
|
7
|
+
|
|
8
|
+
from .core import ObserverMode
|
|
9
|
+
from .otel import get_observer_span_processor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ObserverLogHandler(logging.Handler):
|
|
13
|
+
"""Custom logging handler that captures logs during active traces when observer is enabled."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, level=logging.NOTSET):
|
|
16
|
+
super().__init__(level)
|
|
17
|
+
self._logs_lock = threading.Lock()
|
|
18
|
+
self._trace_logs = {} # trace_id -> list of log records
|
|
19
|
+
|
|
20
|
+
def emit(self, record):
|
|
21
|
+
"""Emit a log record if we're in an active observer trace."""
|
|
22
|
+
try:
|
|
23
|
+
# Get the current span to determine if we're in an active trace
|
|
24
|
+
current_span = trace.get_current_span()
|
|
25
|
+
if not current_span or not current_span.get_span_context().is_valid:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# Get trace and span IDs
|
|
29
|
+
trace_id = f"0x{format_trace_id(current_span.get_span_context().trace_id)}"
|
|
30
|
+
span_id = f"0x{format_span_id(current_span.get_span_context().span_id)}"
|
|
31
|
+
|
|
32
|
+
# Check if observer is recording this trace
|
|
33
|
+
processor = get_observer_span_processor()
|
|
34
|
+
if not processor:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Check if we should record logs for this trace
|
|
38
|
+
with processor._traces_lock:
|
|
39
|
+
if trace_id not in processor._traces:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
trace_info = processor._traces[trace_id]
|
|
43
|
+
# Only capture logs in PERSIST mode
|
|
44
|
+
if trace_info["mode"] != ObserverMode.PERSIST.value:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Store the log record with span context
|
|
48
|
+
log_entry = {
|
|
49
|
+
"record": record,
|
|
50
|
+
"span_id": span_id,
|
|
51
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
with self._logs_lock:
|
|
55
|
+
if trace_id not in self._trace_logs:
|
|
56
|
+
self._trace_logs[trace_id] = []
|
|
57
|
+
self._trace_logs[trace_id].append(log_entry)
|
|
58
|
+
|
|
59
|
+
# Limit logs per trace to prevent memory issues
|
|
60
|
+
if len(self._trace_logs[trace_id]) > 1000:
|
|
61
|
+
self._trace_logs[trace_id] = self._trace_logs[trace_id][-500:]
|
|
62
|
+
|
|
63
|
+
except Exception:
|
|
64
|
+
# Don't let logging errors break the application
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def pop_logs_for_trace(self, trace_id):
|
|
68
|
+
"""Get and remove all logs for a specific trace in one operation."""
|
|
69
|
+
with self._logs_lock:
|
|
70
|
+
return self._trace_logs.pop(trace_id, []).copy()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Global instance of the log handler
|
|
74
|
+
observer_log_handler = ObserverLogHandler()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by Plain 0.61.0 on 2025-09-08 17:09
|
|
2
|
+
|
|
3
|
+
from plain import models
|
|
4
|
+
from plain.models import migrations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
dependencies = [
|
|
9
|
+
("plainobserver", "0003_span_plainobserv_span_id_e7ade3_idx"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="trace",
|
|
15
|
+
name="app_name",
|
|
16
|
+
field=models.CharField(default="", max_length=255, required=False),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name="trace",
|
|
20
|
+
name="app_version",
|
|
21
|
+
field=models.CharField(default="", max_length=255, required=False),
|
|
22
|
+
),
|
|
23
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Generated by Plain 0.61.0 on 2025-09-08 21:12
|
|
2
|
+
|
|
3
|
+
import plain.models.deletion
|
|
4
|
+
from plain import models
|
|
5
|
+
from plain.models import migrations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migration(migrations.Migration):
|
|
9
|
+
dependencies = [
|
|
10
|
+
("plainobserver", "0004_trace_app_name_trace_app_version"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name="Log",
|
|
16
|
+
fields=[
|
|
17
|
+
("id", models.PrimaryKeyField()),
|
|
18
|
+
("timestamp", models.DateTimeField()),
|
|
19
|
+
("level", models.CharField(max_length=20)),
|
|
20
|
+
("logger", models.CharField(max_length=255)),
|
|
21
|
+
("message", models.TextField()),
|
|
22
|
+
(
|
|
23
|
+
"span",
|
|
24
|
+
models.ForeignKey(
|
|
25
|
+
allow_null=True,
|
|
26
|
+
on_delete=plain.models.deletion.SET_NULL,
|
|
27
|
+
related_name="logs",
|
|
28
|
+
required=False,
|
|
29
|
+
to="plainobserver.span",
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"trace",
|
|
34
|
+
models.ForeignKey(
|
|
35
|
+
on_delete=plain.models.deletion.CASCADE,
|
|
36
|
+
related_name="logs",
|
|
37
|
+
to="plainobserver.trace",
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
options={
|
|
42
|
+
"ordering": ["timestamp"],
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
migrations.AddIndex(
|
|
46
|
+
model_name="log",
|
|
47
|
+
index=models.Index(
|
|
48
|
+
fields=["trace", "timestamp"], name="plainobserv_trace_i_fcfb7d_idx"
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
migrations.AddIndex(
|
|
52
|
+
model_name="log",
|
|
53
|
+
index=models.Index(
|
|
54
|
+
fields=["trace", "span"], name="plainobserv_trace_i_1166af_idx"
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
migrations.AddIndex(
|
|
58
|
+
model_name="log",
|
|
59
|
+
index=models.Index(
|
|
60
|
+
fields=["timestamp"], name="plainobserv_timesta_64f0dc_idx"
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
migrations.AddIndex(
|
|
64
|
+
model_name="log",
|
|
65
|
+
index=models.Index(fields=["trace"], name="plainobserv_trace_i_c64ee0_idx"),
|
|
66
|
+
),
|
|
67
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Generated by Plain 0.61.0 on 2025-09-09 15:50
|
|
2
|
+
|
|
3
|
+
from plain.models import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("plainobserver", "0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.RemoveField(
|
|
13
|
+
model_name="log",
|
|
14
|
+
name="logger",
|
|
15
|
+
),
|
|
16
|
+
]
|
plain/observer/models.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import secrets
|
|
3
|
+
from collections import Counter
|
|
3
4
|
from datetime import UTC, datetime
|
|
4
5
|
from functools import cached_property
|
|
5
6
|
|
|
@@ -9,13 +10,24 @@ from opentelemetry.semconv._incubating.attributes import (
|
|
|
9
10
|
session_attributes,
|
|
10
11
|
user_attributes,
|
|
11
12
|
)
|
|
13
|
+
from opentelemetry.semconv._incubating.attributes.code_attributes import (
|
|
14
|
+
CODE_NAMESPACE,
|
|
15
|
+
)
|
|
12
16
|
from opentelemetry.semconv._incubating.attributes.db_attributes import (
|
|
13
17
|
DB_QUERY_PARAMETER_TEMPLATE,
|
|
14
18
|
)
|
|
15
|
-
from opentelemetry.semconv.attributes import db_attributes
|
|
19
|
+
from opentelemetry.semconv.attributes import db_attributes, service_attributes
|
|
20
|
+
from opentelemetry.semconv.attributes.code_attributes import (
|
|
21
|
+
CODE_COLUMN_NUMBER,
|
|
22
|
+
CODE_FILE_PATH,
|
|
23
|
+
CODE_FUNCTION_NAME,
|
|
24
|
+
CODE_LINE_NUMBER,
|
|
25
|
+
CODE_STACKTRACE,
|
|
26
|
+
)
|
|
16
27
|
from opentelemetry.trace import format_trace_id
|
|
17
28
|
|
|
18
29
|
from plain import models
|
|
30
|
+
from plain.runtime import settings
|
|
19
31
|
from plain.urls import reverse
|
|
20
32
|
from plain.utils import timezone
|
|
21
33
|
|
|
@@ -33,6 +45,8 @@ class Trace(models.Model):
|
|
|
33
45
|
request_id = models.CharField(max_length=255, default="", required=False)
|
|
34
46
|
session_id = models.CharField(max_length=255, default="", required=False)
|
|
35
47
|
user_id = models.CharField(max_length=255, default="", required=False)
|
|
48
|
+
app_name = models.CharField(max_length=255, default="", required=False)
|
|
49
|
+
app_version = models.CharField(max_length=255, default="", required=False)
|
|
36
50
|
|
|
37
51
|
# Shareable URL fields
|
|
38
52
|
share_id = models.CharField(max_length=32, default="", required=False)
|
|
@@ -78,31 +92,22 @@ class Trace(models.Model):
|
|
|
78
92
|
return (self.end_time - self.start_time).total_seconds() * 1000
|
|
79
93
|
|
|
80
94
|
def get_trace_summary(self, spans):
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Args:
|
|
84
|
-
spans: Optional list of span objects. If not provided, will query from database.
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
# Count database queries and track duplicates
|
|
88
|
-
query_counts = {}
|
|
89
|
-
db_queries = 0
|
|
90
|
-
|
|
95
|
+
# Count database queries with query text and track duplicates
|
|
96
|
+
query_texts = []
|
|
91
97
|
for span in spans:
|
|
92
|
-
if span.attributes.get(db_attributes.
|
|
93
|
-
|
|
94
|
-
if query_text := span.attributes.get(db_attributes.DB_QUERY_TEXT):
|
|
95
|
-
query_counts[query_text] = query_counts.get(query_text, 0) + 1
|
|
98
|
+
if query_text := span.attributes.get(db_attributes.DB_QUERY_TEXT):
|
|
99
|
+
query_texts.append(query_text)
|
|
96
100
|
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
query_counts = Counter(query_texts)
|
|
102
|
+
query_total = len(query_texts)
|
|
103
|
+
duplicate_count = sum(query_counts.values()) - len(query_counts)
|
|
99
104
|
|
|
100
105
|
# Build summary: "n spans, n queries (n duplicates), Xms"
|
|
101
106
|
parts = []
|
|
102
107
|
|
|
103
108
|
# Queries count with duplicates
|
|
104
|
-
if
|
|
105
|
-
query_part = f"{
|
|
109
|
+
if query_total > 0:
|
|
110
|
+
query_part = f"{query_total} quer{'y' if query_total == 1 else 'ies'}"
|
|
106
111
|
if duplicate_count > 0:
|
|
107
112
|
query_part += f" ({duplicate_count} duplicate{'' if duplicate_count == 1 else 's'})"
|
|
108
113
|
parts.append(query_part)
|
|
@@ -127,6 +132,8 @@ class Trace(models.Model):
|
|
|
127
132
|
request_id = ""
|
|
128
133
|
user_id = ""
|
|
129
134
|
session_id = ""
|
|
135
|
+
app_name = ""
|
|
136
|
+
app_version = ""
|
|
130
137
|
|
|
131
138
|
for span in spans:
|
|
132
139
|
if not span.parent:
|
|
@@ -146,6 +153,15 @@ class Trace(models.Model):
|
|
|
146
153
|
user_id = user_id or span_attrs.get(user_attributes.USER_ID, "")
|
|
147
154
|
session_id = session_id or span_attrs.get(session_attributes.SESSION_ID, "")
|
|
148
155
|
|
|
156
|
+
# Access Resource attributes if not found in span attributes
|
|
157
|
+
if resource := getattr(span, "resource", None):
|
|
158
|
+
app_name = app_name or resource.attributes.get(
|
|
159
|
+
service_attributes.SERVICE_NAME, ""
|
|
160
|
+
)
|
|
161
|
+
app_version = app_version or resource.attributes.get(
|
|
162
|
+
service_attributes.SERVICE_VERSION, ""
|
|
163
|
+
)
|
|
164
|
+
|
|
149
165
|
# Convert timestamps
|
|
150
166
|
start_time = (
|
|
151
167
|
datetime.fromtimestamp(earliest_start / 1_000_000_000, tz=UTC)
|
|
@@ -166,11 +182,22 @@ class Trace(models.Model):
|
|
|
166
182
|
request_id=request_id,
|
|
167
183
|
user_id=user_id,
|
|
168
184
|
session_id=session_id,
|
|
185
|
+
app_name=app_name or getattr(settings, "APP_NAME", ""),
|
|
186
|
+
app_version=app_version or getattr(settings, "APP_VERSION", ""),
|
|
169
187
|
root_span_name=root_span.name if root_span else "",
|
|
170
188
|
)
|
|
171
189
|
|
|
172
190
|
def as_dict(self):
|
|
173
191
|
spans = [span.span_data for span in self.spans.all().order_by("start_time")]
|
|
192
|
+
logs = [
|
|
193
|
+
{
|
|
194
|
+
"timestamp": log.timestamp.isoformat(),
|
|
195
|
+
"level": log.level,
|
|
196
|
+
"message": log.message,
|
|
197
|
+
"span_id": log.span_id,
|
|
198
|
+
}
|
|
199
|
+
for log in self.logs.all().order_by("timestamp")
|
|
200
|
+
]
|
|
174
201
|
|
|
175
202
|
return {
|
|
176
203
|
"trace_id": self.trace_id,
|
|
@@ -182,9 +209,51 @@ class Trace(models.Model):
|
|
|
182
209
|
"request_id": self.request_id,
|
|
183
210
|
"user_id": self.user_id,
|
|
184
211
|
"session_id": self.session_id,
|
|
212
|
+
"app_name": self.app_name,
|
|
213
|
+
"app_version": self.app_version,
|
|
185
214
|
"spans": spans,
|
|
215
|
+
"logs": logs,
|
|
186
216
|
}
|
|
187
217
|
|
|
218
|
+
def get_timeline_events(self):
|
|
219
|
+
"""Get chronological list of spans and logs for unified timeline display."""
|
|
220
|
+
events = []
|
|
221
|
+
|
|
222
|
+
for span in self.spans.all().annotate_spans():
|
|
223
|
+
events.append(
|
|
224
|
+
{
|
|
225
|
+
"type": "span",
|
|
226
|
+
"timestamp": span.start_time,
|
|
227
|
+
"instance": span,
|
|
228
|
+
"span_level": span.level,
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Add logs for this span
|
|
233
|
+
for log in self.logs.filter(span=span):
|
|
234
|
+
events.append(
|
|
235
|
+
{
|
|
236
|
+
"type": "log",
|
|
237
|
+
"timestamp": log.timestamp,
|
|
238
|
+
"instance": log,
|
|
239
|
+
"span_level": span.level + 1,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Add unlinked logs (logs without span)
|
|
244
|
+
for log in self.logs.filter(span__isnull=True):
|
|
245
|
+
events.append(
|
|
246
|
+
{
|
|
247
|
+
"type": "log",
|
|
248
|
+
"timestamp": log.timestamp,
|
|
249
|
+
"instance": log,
|
|
250
|
+
"span_level": 0,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Sort by timestamp
|
|
255
|
+
return sorted(events, key=lambda x: x["timestamp"])
|
|
256
|
+
|
|
188
257
|
|
|
189
258
|
class SpanQuerySet(models.QuerySet):
|
|
190
259
|
def annotate_spans(self):
|
|
@@ -339,6 +408,29 @@ class Span(models.Model):
|
|
|
339
408
|
|
|
340
409
|
return query_params
|
|
341
410
|
|
|
411
|
+
@cached_property
|
|
412
|
+
def source_code_location(self):
|
|
413
|
+
"""Get the source code location attributes from this span."""
|
|
414
|
+
if not self.attributes:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
# Look for common semantic convention code attributes
|
|
418
|
+
code_attrs = {}
|
|
419
|
+
code_attribute_mappings = {
|
|
420
|
+
CODE_FILE_PATH: "File",
|
|
421
|
+
CODE_LINE_NUMBER: "Line",
|
|
422
|
+
CODE_FUNCTION_NAME: "Function",
|
|
423
|
+
CODE_NAMESPACE: "Namespace",
|
|
424
|
+
CODE_COLUMN_NUMBER: "Column",
|
|
425
|
+
CODE_STACKTRACE: "Stacktrace",
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
for attr_key, display_name in code_attribute_mappings.items():
|
|
429
|
+
if attr_key in self.attributes:
|
|
430
|
+
code_attrs[display_name] = self.attributes[attr_key]
|
|
431
|
+
|
|
432
|
+
return code_attrs if code_attrs else None
|
|
433
|
+
|
|
342
434
|
def get_formatted_sql(self):
|
|
343
435
|
"""Get the pretty-formatted SQL query if this span contains one."""
|
|
344
436
|
sql = self.sql_query
|
|
@@ -383,3 +475,39 @@ class Span(models.Model):
|
|
|
383
475
|
exception_attributes.EXCEPTION_STACKTRACE
|
|
384
476
|
)
|
|
385
477
|
return None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@models.register_model
|
|
481
|
+
class Log(models.Model):
|
|
482
|
+
trace = models.ForeignKey(Trace, on_delete=models.CASCADE, related_name="logs")
|
|
483
|
+
span = models.ForeignKey(
|
|
484
|
+
Span,
|
|
485
|
+
on_delete=models.SET_NULL,
|
|
486
|
+
allow_null=True,
|
|
487
|
+
required=False,
|
|
488
|
+
related_name="logs",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
timestamp = models.DateTimeField()
|
|
492
|
+
level = models.CharField(max_length=20)
|
|
493
|
+
message = models.TextField()
|
|
494
|
+
|
|
495
|
+
class Meta:
|
|
496
|
+
ordering = ["timestamp"]
|
|
497
|
+
indexes = [
|
|
498
|
+
models.Index(fields=["trace", "timestamp"]),
|
|
499
|
+
models.Index(fields=["trace", "span"]),
|
|
500
|
+
models.Index(fields=["timestamp"]),
|
|
501
|
+
models.Index(fields=["trace"]),
|
|
502
|
+
]
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def from_log_record(cls, *, record, trace, span):
|
|
506
|
+
"""Create a Log instance from a Python log record."""
|
|
507
|
+
return cls(
|
|
508
|
+
trace=trace,
|
|
509
|
+
timestamp=datetime.fromtimestamp(record.created, tz=UTC),
|
|
510
|
+
level=record.levelname,
|
|
511
|
+
message=record.getMessage(),
|
|
512
|
+
span=span,
|
|
513
|
+
)
|
plain/observer/otel.py
CHANGED
|
@@ -10,6 +10,7 @@ from opentelemetry.semconv.attributes import url_attributes
|
|
|
10
10
|
from opentelemetry.trace import SpanKind, format_span_id, format_trace_id
|
|
11
11
|
|
|
12
12
|
from plain.http.cookie import unsign_cookie_value
|
|
13
|
+
from plain.logs import app_logger
|
|
13
14
|
from plain.models.otel import suppress_db_tracing
|
|
14
15
|
from plain.runtime import settings
|
|
15
16
|
|
|
@@ -218,15 +219,12 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
218
219
|
trace_info = self._traces[trace_id]
|
|
219
220
|
trace_info["mode"] = mode
|
|
220
221
|
|
|
221
|
-
# Clean up old traces if too many
|
|
222
|
-
if len(self._traces) > 1000:
|
|
223
|
-
# Remove oldest 100 traces
|
|
224
|
-
oldest_ids = sorted(self._traces.keys())[:100]
|
|
225
|
-
for old_id in oldest_ids:
|
|
226
|
-
del self._traces[old_id]
|
|
227
|
-
|
|
228
222
|
span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
|
|
229
223
|
|
|
224
|
+
# Enable DEBUG logging only for PERSIST mode (when logs are captured)
|
|
225
|
+
if trace_info["mode"] == ObserverMode.PERSIST.value:
|
|
226
|
+
app_logger.debug_mode.start()
|
|
227
|
+
|
|
230
228
|
# Store span (we know mode is truthy if we get here)
|
|
231
229
|
trace_info["active_otel_spans"][span_id] = span
|
|
232
230
|
|
|
@@ -246,6 +244,10 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
246
244
|
|
|
247
245
|
trace_info = self._traces[trace_id]
|
|
248
246
|
|
|
247
|
+
# Disable DEBUG logging only for PERSIST mode spans
|
|
248
|
+
if trace_info["mode"] == ObserverMode.PERSIST.value:
|
|
249
|
+
app_logger.debug_mode.end()
|
|
250
|
+
|
|
249
251
|
# Move span from active to completed
|
|
250
252
|
if trace_info["active_otel_spans"].pop(span_id, None):
|
|
251
253
|
trace_info["completed_otel_spans"].append(span)
|
|
@@ -264,16 +266,29 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
264
266
|
|
|
265
267
|
# Export if in persist mode
|
|
266
268
|
if trace_info["mode"] == ObserverMode.PERSIST.value:
|
|
269
|
+
# Get and remove logs for this trace
|
|
270
|
+
from .logging import observer_log_handler
|
|
271
|
+
|
|
272
|
+
if observer_log_handler:
|
|
273
|
+
logs = observer_log_handler.pop_logs_for_trace(trace_id)
|
|
274
|
+
else:
|
|
275
|
+
logs = []
|
|
276
|
+
|
|
267
277
|
logger.debug(
|
|
268
|
-
"Exporting %d spans for trace %s",
|
|
278
|
+
"Exporting %d spans and %d logs for trace %s",
|
|
269
279
|
len(trace_info["span_models"]),
|
|
280
|
+
len(logs),
|
|
270
281
|
trace_id,
|
|
271
282
|
)
|
|
272
283
|
# The trace is done now, so we can get a more accurate summary
|
|
273
284
|
trace_info["trace"].summary = trace_info["trace"].get_trace_summary(
|
|
274
285
|
trace_info["span_models"]
|
|
275
286
|
)
|
|
276
|
-
self._export_trace(
|
|
287
|
+
self._export_trace(
|
|
288
|
+
trace=trace_info["trace"],
|
|
289
|
+
spans=trace_info["span_models"],
|
|
290
|
+
logs=logs,
|
|
291
|
+
)
|
|
277
292
|
|
|
278
293
|
# Clean up trace
|
|
279
294
|
del self._traces[trace_id]
|
|
@@ -315,19 +330,38 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
315
330
|
|
|
316
331
|
return trace_info["trace"].get_trace_summary(span_models)
|
|
317
332
|
|
|
318
|
-
def _export_trace(self, trace,
|
|
319
|
-
"""Export trace and
|
|
320
|
-
from .models import Span, Trace
|
|
333
|
+
def _export_trace(self, *, trace, spans, logs):
|
|
334
|
+
"""Export trace, spans, and logs to the database."""
|
|
335
|
+
from .models import Log, Span, Trace
|
|
321
336
|
|
|
322
337
|
with suppress_db_tracing():
|
|
323
338
|
try:
|
|
324
339
|
trace.save()
|
|
325
340
|
|
|
326
|
-
for
|
|
327
|
-
|
|
341
|
+
for span in spans:
|
|
342
|
+
span.trace = trace
|
|
328
343
|
|
|
329
344
|
# Bulk create spans
|
|
330
|
-
Span.objects.bulk_create(
|
|
345
|
+
Span.objects.bulk_create(spans)
|
|
346
|
+
|
|
347
|
+
# Create log models if we have logs
|
|
348
|
+
if logs:
|
|
349
|
+
# Create a mapping of span_id to span_model
|
|
350
|
+
span_id_to_model = {
|
|
351
|
+
span_model.span_id: span_model for span_model in spans
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
log_models = []
|
|
355
|
+
for log_entry in logs:
|
|
356
|
+
log_model = Log.from_log_record(
|
|
357
|
+
record=log_entry["record"],
|
|
358
|
+
trace=trace,
|
|
359
|
+
span=span_id_to_model.get(log_entry["span_id"]),
|
|
360
|
+
)
|
|
361
|
+
log_models.append(log_model)
|
|
362
|
+
|
|
363
|
+
Log.objects.bulk_create(log_models)
|
|
364
|
+
|
|
331
365
|
except Exception as e:
|
|
332
366
|
logger.warning(
|
|
333
367
|
"Failed to export trace to database: %s",
|
|
@@ -339,8 +373,11 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
339
373
|
if settings.OBSERVER_TRACE_LIMIT > 0:
|
|
340
374
|
try:
|
|
341
375
|
if Trace.objects.count() > settings.OBSERVER_TRACE_LIMIT:
|
|
376
|
+
excess_count = (
|
|
377
|
+
Trace.objects.count() - settings.OBSERVER_TRACE_LIMIT
|
|
378
|
+
)
|
|
342
379
|
delete_ids = Trace.objects.order_by("start_time")[
|
|
343
|
-
:
|
|
380
|
+
:excess_count
|
|
344
381
|
].values_list("id", flat=True)
|
|
345
382
|
Trace.objects.filter(id__in=delete_ids).delete()
|
|
346
383
|
except Exception as e:
|