plain.observer 0.5.0__tar.gz → 0.6.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.5.0 → plain_observer-0.6.1}/PKG-INFO +2 -2
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/CHANGELOG.md +30 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/admin.py +46 -1
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/config.py +22 -1
- plain_observer-0.6.1/plain/observer/logging.py +75 -0
- plain_observer-0.6.1/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +23 -0
- plain_observer-0.6.1/plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +67 -0
- plain_observer-0.6.1/plain/observer/migrations/0006_remove_log_logger.py +16 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/models.py +136 -19
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/otel.py +55 -16
- plain_observer-0.6.1/plain/observer/templates/observer/partials/log.html +15 -0
- plain_observer-0.6.1/plain/observer/templates/observer/partials/span.html +299 -0
- plain_observer-0.6.1/plain/observer/templates/observer/trace.html +193 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/traces.html +50 -28
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/views.py +15 -9
- {plain_observer-0.5.0 → plain_observer-0.6.1}/pyproject.toml +2 -2
- plain_observer-0.5.0/plain/observer/templates/observer/trace.html +0 -421
- {plain_observer-0.5.0 → plain_observer-0.6.1}/.gitignore +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/LICENSE +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/README.md +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/README.md +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/__init__.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/cli.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/core.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/default_settings.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/0001_initial.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/__init__.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/trace_detail.html +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/trace_share.html +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/toolbar/observer.html +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/toolbar/observer_button.html +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/toolbar.py +0 -0
- {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/urls.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.observer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: On-page telemetry and observability tools for Plain.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
9
|
Requires-Dist: opentelemetry-sdk>=1.34.1
|
|
10
10
|
Requires-Dist: plain-admin<1.0.0
|
|
11
11
|
Requires-Dist: plain<1.0.0
|
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# plain-observer changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.1](https://github.com/dropseed/plain/releases/plain-observer@0.6.1) (2025-09-09)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Log messages are now stored in their formatted form instead of as raw log records, improving display consistency and performance ([b646699](https://github.com/dropseed/plain/commit/b646699e46))
|
|
8
|
+
- Observer log handler now copies the formatter from the app logger to ensure consistent log formatting ([b646699](https://github.com/dropseed/plain/commit/b646699e46))
|
|
9
|
+
- Simplified log display template by removing redundant level display element ([b646699](https://github.com/dropseed/plain/commit/b646699e46))
|
|
10
|
+
|
|
11
|
+
### Upgrade instructions
|
|
12
|
+
|
|
13
|
+
- No changes required
|
|
14
|
+
|
|
15
|
+
## [0.6.0](https://github.com/dropseed/plain/releases/plain-observer@0.6.0) (2025-09-09)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- 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))
|
|
20
|
+
- Added new Log model with admin interface for managing captured log entries ([9bfe938](https://github.com/dropseed/plain/commit/9bfe938f64))
|
|
21
|
+
- Observer now automatically enables debug logging during trace recording to capture more detailed information ([731196](https://github.com/dropseed/plain/commit/731196086f))
|
|
22
|
+
- Added app_name and app_version fields to trace records for better application identification ([2870636](https://github.com/dropseed/plain/commit/2870636944))
|
|
23
|
+
- Added span count display in trace detail views ([4d22c10](https://github.com/dropseed/plain/commit/4d22c1058d))
|
|
24
|
+
- Enhanced database query counting to only include queries with actual query text, providing more accurate metrics ([3d102d3](https://github.com/dropseed/plain/commit/3d102d3796))
|
|
25
|
+
- Improved trace limit cleanup logic to properly maintain the configured trace limit ([e9d124b](https://github.com/dropseed/plain/commit/e9d124bccd))
|
|
26
|
+
- Added source code location attributes support for spans with file path, line number, and function information ([da36a17](https://github.com/dropseed/plain/commit/da36a17dab))
|
|
27
|
+
- Updated Python version requirement to 3.13 minimum ([d86e307](https://github.com/dropseed/plain/commit/d86e307efb))
|
|
28
|
+
|
|
29
|
+
### Upgrade instructions
|
|
30
|
+
|
|
31
|
+
- No changes required
|
|
32
|
+
|
|
3
33
|
## [0.5.0](https://github.com/dropseed/plain/releases/plain-observer@0.5.0) (2025-09-03)
|
|
4
34
|
|
|
5
35
|
### What's 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
|
|
@@ -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,26 @@ 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
|
+
# Copy formatter from existing app_logger handler to match log formatting
|
|
50
|
+
for handler in app_logger.handlers:
|
|
51
|
+
if handler.formatter:
|
|
52
|
+
observer_log_handler.setFormatter(handler.formatter)
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
app_logger.addHandler(observer_log_handler)
|
|
56
|
+
|
|
36
57
|
@staticmethod
|
|
37
58
|
def get_existing_trace_provider():
|
|
38
59
|
"""Return the currently configured provider if set."""
|
|
@@ -0,0 +1,75 @@
|
|
|
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 formatted message with span context
|
|
48
|
+
log_entry = {
|
|
49
|
+
"message": self.format(record),
|
|
50
|
+
"level": record.levelname,
|
|
51
|
+
"span_id": span_id,
|
|
52
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
with self._logs_lock:
|
|
56
|
+
if trace_id not in self._trace_logs:
|
|
57
|
+
self._trace_logs[trace_id] = []
|
|
58
|
+
self._trace_logs[trace_id].append(log_entry)
|
|
59
|
+
|
|
60
|
+
# Limit logs per trace to prevent memory issues
|
|
61
|
+
if len(self._trace_logs[trace_id]) > 1000:
|
|
62
|
+
self._trace_logs[trace_id] = self._trace_logs[trace_id][-500:]
|
|
63
|
+
|
|
64
|
+
except Exception:
|
|
65
|
+
# Don't let logging errors break the application
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def pop_logs_for_trace(self, trace_id):
|
|
69
|
+
"""Get and remove all logs for a specific trace in one operation."""
|
|
70
|
+
with self._logs_lock:
|
|
71
|
+
return self._trace_logs.pop(trace_id, []).copy()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Global instance of the log handler
|
|
75
|
+
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
|
+
]
|
|
@@ -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,28 @@ 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
|
+
]
|