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.

Files changed (35) hide show
  1. {plain_observer-0.5.0 → plain_observer-0.6.1}/PKG-INFO +2 -2
  2. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/CHANGELOG.md +30 -0
  3. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/admin.py +46 -1
  4. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/config.py +22 -1
  5. plain_observer-0.6.1/plain/observer/logging.py +75 -0
  6. plain_observer-0.6.1/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +23 -0
  7. plain_observer-0.6.1/plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +67 -0
  8. plain_observer-0.6.1/plain/observer/migrations/0006_remove_log_logger.py +16 -0
  9. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/models.py +136 -19
  10. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/otel.py +55 -16
  11. plain_observer-0.6.1/plain/observer/templates/observer/partials/log.html +15 -0
  12. plain_observer-0.6.1/plain/observer/templates/observer/partials/span.html +299 -0
  13. plain_observer-0.6.1/plain/observer/templates/observer/trace.html +193 -0
  14. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/traces.html +50 -28
  15. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/views.py +15 -9
  16. {plain_observer-0.5.0 → plain_observer-0.6.1}/pyproject.toml +2 -2
  17. plain_observer-0.5.0/plain/observer/templates/observer/trace.html +0 -421
  18. {plain_observer-0.5.0 → plain_observer-0.6.1}/.gitignore +0 -0
  19. {plain_observer-0.5.0 → plain_observer-0.6.1}/LICENSE +0 -0
  20. {plain_observer-0.5.0 → plain_observer-0.6.1}/README.md +0 -0
  21. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/README.md +0 -0
  22. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/__init__.py +0 -0
  23. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/cli.py +0 -0
  24. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/core.py +0 -0
  25. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/default_settings.py +0 -0
  26. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/0001_initial.py +0 -0
  27. {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
  28. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
  29. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/migrations/__init__.py +0 -0
  30. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/trace_detail.html +0 -0
  31. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/observer/trace_share.html +0 -0
  32. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/toolbar/observer.html +0 -0
  33. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/templates/toolbar/observer_button.html +0 -0
  34. {plain_observer-0.5.0 → plain_observer-0.6.1}/plain/observer/toolbar.py +0 -0
  35. {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.5.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.11
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
- provider = TracerProvider(sampler=sampler)
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
- """Get a concise summary string for toolbar display.
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.DB_SYSTEM_NAME):
93
- db_queries += 1
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
- # Count duplicate queries (queries that appear more than once)
98
- duplicate_count = sum(count - 1 for count in query_counts.values() if count > 1)
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 db_queries > 0:
105
- query_part = f"{db_queries} quer{'y' if db_queries == 1 else 'ies'}"
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
+ ]