- {% if toolbar.request_exception() %}
-
-
-
-
-
+ {% for panel in panels %}
+ {% if panel.button_template_name %}
+ {{ panel.render_button() }}
{% endif %}
+ {% endfor %}
Admin
diff --git a/plain-admin/plain/admin/toolbar.py b/plain-admin/plain/admin/toolbar.py
index fba30b9a20..1d03e93308 100644
--- a/plain-admin/plain/admin/toolbar.py
+++ b/plain-admin/plain/admin/toolbar.py
@@ -49,6 +49,7 @@ def get_panels(self):
class ToolbarPanel:
name: str
template_name: str
+ button_template_name: str = ""
def __init__(self, request):
self.request = request
@@ -63,6 +64,14 @@ def render(self):
context = self.get_template_context()
return mark_safe(template.render(context))
+ def render_button(self):
+ """Render the toolbar button for the minimized state."""
+ if not self.button_template_name:
+ return ""
+ template = Template(self.button_template_name)
+ context = self.get_template_context()
+ return mark_safe(template.render(context))
+
class _ToolbarPanelRegistry:
def __init__(self):
@@ -86,6 +95,7 @@ def register_toolbar_panel(panel_class):
class _ExceptionToolbarPanel(ToolbarPanel):
name = "Exception"
template_name = "toolbar/exception.html"
+ button_template_name = "toolbar/exception_button.html"
def __init__(self, request, exception):
super().__init__(request)
@@ -101,9 +111,3 @@ def get_template_context(self):
class _RequestToolbarPanel(ToolbarPanel):
name = "Request"
template_name = "toolbar/request.html"
-
-
-@register_toolbar_panel
-class _QuerystatsToolbarPanel(ToolbarPanel):
- name = "Queries"
- template_name = "toolbar/querystats.html"
diff --git a/plain-admin/plain/admin/urls.py b/plain-admin/plain/admin/urls.py
index 07ccab5c98..81695a5ece 100644
--- a/plain-admin/plain/admin/urls.py
+++ b/plain-admin/plain/admin/urls.py
@@ -2,7 +2,6 @@
from plain.urls import Router, include, path
from .impersonate.urls import ImpersonateRouter
-from .querystats.urls import QuerystatsRouter
from .views.base import AdminView
from .views.registry import registry
@@ -36,7 +35,6 @@ class AdminRouter(Router):
urls = [
path("search/", AdminSearchView, name="search"),
include("impersonate/", ImpersonateRouter),
- include("querystats/", QuerystatsRouter),
include("", registry.get_urls()),
path("", AdminIndexView, name="index"),
]
diff --git a/plain-admin/pyproject.toml b/plain-admin/pyproject.toml
index 39b9b03c54..f1406fcf5f 100644
--- a/plain-admin/pyproject.toml
+++ b/plain-admin/pyproject.toml
@@ -11,7 +11,6 @@ dependencies = [
"plain.auth<1.0.0",
"plain.htmx<1.0.0",
"plain.tailwind<1.0.0",
- "sqlparse>=0.2.2",
]
[tool.uv]
diff --git a/plain-auth/plain/auth/middleware.py b/plain-auth/plain/auth/middleware.py
index 1f6e295075..385ac58378 100644
--- a/plain-auth/plain/auth/middleware.py
+++ b/plain-auth/plain/auth/middleware.py
@@ -1,3 +1,6 @@
+from opentelemetry import trace
+from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
+
from plain import auth
from plain.exceptions import ImproperlyConfigured
from plain.utils.functional import SimpleLazyObject
@@ -6,6 +9,8 @@
def get_user(request):
if not hasattr(request, "_cached_user"):
request._cached_user = auth.get_user(request)
+ if request._cached_user:
+ trace.get_current_span().set_attribute(USER_ID, request._cached_user.id)
return request._cached_user
diff --git a/plain-cache/plain/cache/core.py b/plain-cache/plain/cache/core.py
index e612c0e973..2b6334e6b4 100644
--- a/plain-cache/plain/cache/core.py
+++ b/plain-cache/plain/cache/core.py
@@ -1,9 +1,19 @@
from datetime import datetime, timedelta
from functools import cached_property
+from opentelemetry import trace
+from opentelemetry.semconv.attributes.db_attributes import (
+ DB_NAMESPACE,
+ DB_OPERATION_NAME,
+ DB_SYSTEM_NAME,
+)
+from opentelemetry.trace import SpanKind
+
from plain.models import IntegrityError
from plain.utils import timezone
+tracer = trace.get_tracer("plain.cache")
+
class Cached:
"""Store and retrieve cached items."""
@@ -38,17 +48,49 @@ def _is_expired(self):
return self._model_instance.expires_at < timezone.now()
def exists(self) -> bool:
- if self._model_instance is None:
- return False
-
- return not self._is_expired()
+ with tracer.start_as_current_span(
+ "cache.exists",
+ kind=SpanKind.CLIENT,
+ attributes={
+ DB_SYSTEM_NAME: "plain.cache",
+ DB_OPERATION_NAME: "get",
+ DB_NAMESPACE: "cache",
+ "cache.key": self.key,
+ },
+ ) as span:
+ span.set_status(trace.StatusCode.OK)
+
+ if self._model_instance is None:
+ return False
+
+ return not self._is_expired()
@property
def value(self):
- if not self.exists():
- return None
-
- return self._model_instance.value
+ with tracer.start_as_current_span(
+ "cache.get",
+ kind=SpanKind.CLIENT,
+ attributes={
+ DB_SYSTEM_NAME: "plain.cache",
+ DB_OPERATION_NAME: "get",
+ DB_NAMESPACE: "cache",
+ "cache.key": self.key,
+ },
+ ) as span:
+ if self._model_instance and self._model_instance.expires_at:
+ span.set_attribute(
+ "cache.item.expires_at", self._model_instance.expires_at.isoformat()
+ )
+
+ exists = self.exists()
+
+ span.set_attribute("cache.hit", exists)
+ span.set_status(trace.StatusCode.OK if exists else trace.StatusCode.UNSET)
+
+ if not exists:
+ return None
+
+ return self._model_instance.value
def set(self, value, expiration: datetime | timedelta | int | float | None = None):
defaults = {
@@ -66,28 +108,57 @@ def set(self, value, expiration: datetime | timedelta | int | float | None = Non
pass
# Make sure expires_at is timezone aware
- if defaults["expires_at"] and not timezone.is_aware(defaults["expires_at"]):
+ if (
+ "expires_at" in defaults
+ and defaults["expires_at"]
+ and not timezone.is_aware(defaults["expires_at"])
+ ):
defaults["expires_at"] = timezone.make_aware(defaults["expires_at"])
- try:
- item, _ = self._model_class.objects.update_or_create(
- key=self.key, defaults=defaults
- )
- except IntegrityError:
- # Most likely a race condition in creating the item,
- # so trying again should do an update
- item, _ = self._model_class.objects.update_or_create(
- key=self.key, defaults=defaults
- )
-
- self.reload()
- return item.value
+ with tracer.start_as_current_span(
+ "cache.set",
+ kind=SpanKind.CLIENT,
+ attributes={
+ DB_SYSTEM_NAME: "plain.cache",
+ DB_OPERATION_NAME: "set",
+ DB_NAMESPACE: "cache",
+ "cache.key": self.key,
+ },
+ ) as span:
+ if expires_at := defaults.get("expires_at"):
+ span.set_attribute("cache.item.expires_at", expires_at.isoformat())
+
+ try:
+ item, _ = self._model_class.objects.update_or_create(
+ key=self.key, defaults=defaults
+ )
+ except IntegrityError:
+ # Most likely a race condition in creating the item,
+ # so trying again should do an update
+ item, _ = self._model_class.objects.update_or_create(
+ key=self.key, defaults=defaults
+ )
+
+ self.reload()
+ span.set_status(trace.StatusCode.OK)
+ return item.value
def delete(self) -> bool:
- if not self._model_instance:
- # A no-op, but a return value you can use to know whether it did anything
- return False
-
- self._model_instance.delete()
- self.reload()
- return True
+ with tracer.start_as_current_span(
+ "cache.delete",
+ kind=SpanKind.CLIENT,
+ attributes={
+ DB_SYSTEM_NAME: "plain.cache",
+ DB_OPERATION_NAME: "delete",
+ DB_NAMESPACE: "cache",
+ "cache.key": self.key,
+ },
+ ) as span:
+ span.set_status(trace.StatusCode.OK)
+ if not self._model_instance:
+ # A no-op, but a return value you can use to know whether it did anything
+ return False
+
+ self._model_instance.delete()
+ self.reload()
+ return True
diff --git a/plain-flags/plain/flags/flags.py b/plain-flags/plain/flags/flags.py
index 0350f42f07..8283333c9a 100644
--- a/plain-flags/plain/flags/flags.py
+++ b/plain-flags/plain/flags/flags.py
@@ -2,6 +2,15 @@
from functools import cached_property
from typing import Any
+from opentelemetry import trace
+from opentelemetry.semconv._incubating.attributes.feature_flag_attributes import (
+ FEATURE_FLAG_KEY,
+ FEATURE_FLAG_PROVIDER_NAME,
+ FEATURE_FLAG_RESULT_REASON,
+ FEATURE_FLAG_RESULT_VALUE,
+ FeatureFlagResultReasonValues,
+)
+
from plain.runtime import settings
from plain.utils import timezone
@@ -9,6 +18,7 @@
from .utils import coerce_key
logger = logging.getLogger(__name__)
+tracer = trace.get_tracer("plain.flags")
class Flag:
@@ -49,35 +59,75 @@ def retrieve_or_compute_value(self) -> Any:
"""
from .models import Flag, FlagResult # So Plain app is ready...
- # Create an associated DB Flag that we can use to enable/disable
- # and tie the results to
- flag_obj, _ = Flag.objects.update_or_create(
- name=self.get_db_name(),
- defaults={"used_at": timezone.now()},
- )
- if not flag_obj.enabled:
- msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
- if settings.DEBUG:
- raise exceptions.FlagDisabled(msg)
- else:
- logger.exception(msg)
- # Might not be the type of return value expected! Better than totally crashing now though.
- return None
-
- key = self.get_key()
- if not key:
- # No key, so we always recompute the value and return it
- return self.get_value()
-
- key = coerce_key(key)
-
- try:
- flag_result = FlagResult.objects.get(flag=flag_obj, key=key)
- return flag_result.value
- except FlagResult.DoesNotExist:
- value = self.get_value()
- flag_result = FlagResult.objects.create(flag=flag_obj, key=key, value=value)
- return flag_result.value
+ flag_name = self.get_db_name()
+
+ with tracer.start_as_current_span(
+ f"flag {flag_name}",
+ attributes={
+ FEATURE_FLAG_PROVIDER_NAME: "plain.flags",
+ },
+ ) as span:
+ # Create an associated DB Flag that we can use to enable/disable
+ # and tie the results to
+ flag_obj, _ = Flag.objects.update_or_create(
+ name=flag_name,
+ defaults={"used_at": timezone.now()},
+ )
+
+ if not flag_obj.enabled:
+ msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
+ span.set_attribute(
+ FEATURE_FLAG_RESULT_REASON,
+ FeatureFlagResultReasonValues.DISABLED.value,
+ )
+
+ if settings.DEBUG:
+ raise exceptions.FlagDisabled(msg)
+ else:
+ logger.exception(msg)
+ # Might not be the type of return value expected! Better than totally crashing now though.
+ return None
+
+ key = self.get_key()
+ if not key:
+ # No key, so we always recompute the value and return it
+ value = self.get_value()
+
+ span.set_attribute(
+ FEATURE_FLAG_RESULT_REASON,
+ FeatureFlagResultReasonValues.DYNAMIC.value,
+ )
+ span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
+
+ return value
+
+ key = coerce_key(key)
+
+ span.set_attribute(FEATURE_FLAG_KEY, key)
+
+ try:
+ flag_result = FlagResult.objects.get(flag=flag_obj, key=key)
+
+ span.set_attribute(
+ FEATURE_FLAG_RESULT_REASON,
+ FeatureFlagResultReasonValues.CACHED.value,
+ )
+ span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(flag_result.value))
+
+ return flag_result.value
+ except FlagResult.DoesNotExist:
+ value = self.get_value()
+ flag_result = FlagResult.objects.create(
+ flag=flag_obj, key=key, value=value
+ )
+
+ span.set_attribute(
+ FEATURE_FLAG_RESULT_REASON,
+ FeatureFlagResultReasonValues.STATIC.value,
+ )
+ span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
+
+ return flag_result.value
@cached_property
def value(self) -> Any:
diff --git a/plain-models/plain/models/backends/utils.py b/plain-models/plain/models/backends/utils.py
index b61ef19fd7..65679c6d89 100644
--- a/plain-models/plain/models/backends/utils.py
+++ b/plain-models/plain/models/backends/utils.py
@@ -7,6 +7,7 @@
from hashlib import md5
from plain.models.db import NotSupportedError
+from plain.models.otel import db_span
from plain.utils.dateparse import parse_time
logger = logging.getLogger("plain.models.backends")
@@ -80,18 +81,20 @@ def _execute_with_wrappers(self, sql, params, many, executor):
return executor(sql, params, many, context)
def _execute(self, sql, params, *ignored_wrapper_args):
- self.db.validate_no_broken_transaction()
- with self.db.wrap_database_errors:
- if params is None:
- # params default might be backend specific.
- return self.cursor.execute(sql)
- else:
- return self.cursor.execute(sql, params)
+ # Wrap in an OpenTelemetry span with standard attributes.
+ with db_span(self.db, sql, params=params):
+ self.db.validate_no_broken_transaction()
+ with self.db.wrap_database_errors:
+ if params is None:
+ return self.cursor.execute(sql)
+ else:
+ return self.cursor.execute(sql, params)
def _executemany(self, sql, param_list, *ignored_wrapper_args):
- self.db.validate_no_broken_transaction()
- with self.db.wrap_database_errors:
- return self.cursor.executemany(sql, param_list)
+ with db_span(self.db, sql, many=True, params=param_list):
+ self.db.validate_no_broken_transaction()
+ with self.db.wrap_database_errors:
+ return self.cursor.executemany(sql, param_list)
class CursorDebugWrapper(CursorWrapper):
diff --git a/plain-models/plain/models/otel.py b/plain-models/plain/models/otel.py
new file mode 100644
index 0000000000..abab566e7b
--- /dev/null
+++ b/plain-models/plain/models/otel.py
@@ -0,0 +1,175 @@
+import re
+from contextlib import contextmanager
+from typing import Any
+
+from opentelemetry import context as otel_context
+from opentelemetry import trace
+from opentelemetry.semconv._incubating.attributes.db_attributes import (
+ DB_QUERY_PARAMETER_TEMPLATE,
+ DB_USER,
+)
+from opentelemetry.semconv.attributes.db_attributes import (
+ DB_COLLECTION_NAME,
+ DB_NAMESPACE,
+ DB_OPERATION_NAME,
+ DB_QUERY_SUMMARY,
+ DB_QUERY_TEXT,
+ DB_SYSTEM_NAME,
+)
+from opentelemetry.semconv.attributes.network_attributes import (
+ NETWORK_PEER_ADDRESS,
+ NETWORK_PEER_PORT,
+)
+from opentelemetry.semconv.trace import DbSystemValues
+from opentelemetry.trace import SpanKind
+
+from plain.runtime import settings
+
+_SUPPRESS_KEY = object()
+
+tracer = trace.get_tracer("plain.models")
+
+
+def db_system_for(vendor: str) -> str: # noqa: D401 – simple helper
+ """Return the canonical ``db.system.name`` value for a backend vendor."""
+
+ return {
+ "postgresql": DbSystemValues.POSTGRESQL.value,
+ "mysql": DbSystemValues.MYSQL.value,
+ "mariadb": DbSystemValues.MARIADB.value,
+ "sqlite": DbSystemValues.SQLITE.value,
+ }.get(vendor, vendor)
+
+
+def extract_operation_and_target(sql: str) -> tuple[str, str | None, str | None]:
+ """Extract operation, table name, and collection from SQL.
+
+ Returns: (operation, summary, collection_name)
+ """
+ sql_upper = sql.upper().strip()
+ operation = sql_upper.split()[0] if sql_upper else "UNKNOWN"
+
+ # Pattern to match quoted and unquoted identifiers
+ # Matches: "quoted", `quoted`, [quoted], unquoted.name
+ identifier_pattern = r'("([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([\w.]+))'
+
+ # Extract table/collection name based on operation
+ collection_name = None
+ summary = operation
+
+ if operation in ("SELECT", "DELETE"):
+ match = re.search(rf"FROM\s+{identifier_pattern}", sql, re.IGNORECASE)
+ if match:
+ collection_name = _clean_identifier(match.group(1))
+ summary = f"{operation} {collection_name}"
+
+ elif operation in ("INSERT", "REPLACE"):
+ match = re.search(rf"INTO\s+{identifier_pattern}", sql, re.IGNORECASE)
+ if match:
+ collection_name = _clean_identifier(match.group(1))
+ summary = f"{operation} {collection_name}"
+
+ elif operation == "UPDATE":
+ match = re.search(rf"UPDATE\s+{identifier_pattern}", sql, re.IGNORECASE)
+ if match:
+ collection_name = _clean_identifier(match.group(1))
+ summary = f"{operation} {collection_name}"
+
+ return operation, summary, collection_name
+
+
+def _clean_identifier(identifier: str) -> str:
+ """Remove quotes from SQL identifiers."""
+ # Remove different types of SQL quotes
+ if identifier.startswith('"') and identifier.endswith('"'):
+ return identifier[1:-1]
+ elif identifier.startswith("`") and identifier.endswith("`"):
+ return identifier[1:-1]
+ elif identifier.startswith("[") and identifier.endswith("]"):
+ return identifier[1:-1]
+ return identifier
+
+
+@contextmanager
+def db_span(db, sql: Any, *, many: bool = False, params=None):
+ """Open an OpenTelemetry CLIENT span for a database query.
+
+ All common attributes (`db.*`, `network.*`, etc.) are set automatically.
+ Follows OpenTelemetry semantic conventions for database instrumentation.
+ """
+
+ # Fast-exit if instrumentation suppression flag set in context.
+ if otel_context.get_value(_SUPPRESS_KEY):
+ yield None
+ return
+
+ sql = str(sql) # Ensure SQL is a string for span attributes.
+
+ # Extract operation and target information
+ operation, summary, collection_name = extract_operation_and_target(sql)
+
+ if many:
+ summary = f"{summary} many"
+
+ # Span name follows semantic conventions: {target} or {db.operation.name} {target}
+ if summary:
+ span_name = summary[:255]
+ else:
+ span_name = operation
+
+ # Build attribute set following semantic conventions
+ attrs: dict[str, Any] = {
+ DB_SYSTEM_NAME: db_system_for(db.vendor),
+ DB_NAMESPACE: db.settings_dict.get("NAME"),
+ DB_QUERY_TEXT: sql, # Already parameterized from Django/Plain
+ DB_QUERY_SUMMARY: summary,
+ DB_OPERATION_NAME: operation,
+ }
+
+ # Add collection name if detected
+ if collection_name:
+ attrs[DB_COLLECTION_NAME] = collection_name
+
+ # Add user attribute
+ if user := db.settings_dict.get("USER"):
+ attrs[DB_USER] = user
+
+ # Network attributes
+ if host := db.settings_dict.get("HOST"):
+ attrs[NETWORK_PEER_ADDRESS] = host
+
+ if port := db.settings_dict.get("PORT"):
+ try:
+ attrs[NETWORK_PEER_PORT] = int(port)
+ except (TypeError, ValueError):
+ pass
+
+ # Add query parameters as attributes when DEBUG is True
+ if settings.DEBUG and params is not None:
+ # Convert params to appropriate format based on type
+ if isinstance(params, dict):
+ # Dictionary params (e.g., for named placeholders)
+ for i, (key, value) in enumerate(params.items()):
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{key}"] = str(value)
+ elif isinstance(params, list | tuple):
+ # Sequential params (e.g., for %s or ? placeholders)
+ for i, value in enumerate(params):
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{i + 1}"] = str(value)
+ else:
+ # Single param (rare but possible)
+ attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.1"] = str(params)
+
+ with tracer.start_as_current_span(
+ span_name, kind=SpanKind.CLIENT, attributes=attrs
+ ) as span:
+ yield span
+ span.set_status(trace.StatusCode.OK)
+
+
+@contextmanager
+def suppress_db_tracing():
+ token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True))
+ try:
+ yield
+ finally:
+ otel_context.detach(token)
diff --git a/plain-models/plain/models/test/pytest.py b/plain-models/plain/models/test/pytest.py
index c717d73a2e..d8cf9735f2 100644
--- a/plain-models/plain/models/test/pytest.py
+++ b/plain-models/plain/models/test/pytest.py
@@ -2,6 +2,7 @@
import pytest
+from plain.models.otel import suppress_db_tracing
from plain.signals import request_finished, request_started
from .. import transaction
@@ -60,29 +61,32 @@ def setup_db(request):
def db(setup_db, request):
if "isolated_db" in request.fixturenames:
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
+
# Set .cursor() back to the original implementation to unblock it
BaseDatabaseWrapper.cursor = BaseDatabaseWrapper._enabled_cursor
if not db_connection.features.supports_transactions:
pytest.fail("Database does not support transactions")
- atomic = transaction.atomic()
- atomic._from_testcase = True # TODO remove this somehow?
- atomic.__enter__()
+ with suppress_db_tracing():
+ atomic = transaction.atomic()
+ atomic._from_testcase = True # TODO remove this somehow?
+ atomic.__enter__()
yield
- if (
- db_connection.features.can_defer_constraint_checks
- and not db_connection.needs_rollback
- and db_connection.is_usable()
- ):
- db_connection.check_constraints()
+ with suppress_db_tracing():
+ if (
+ db_connection.features.can_defer_constraint_checks
+ and not db_connection.needs_rollback
+ and db_connection.is_usable()
+ ):
+ db_connection.check_constraints()
- db_connection.set_rollback(True)
- atomic.__exit__(None, None, None)
+ db_connection.set_rollback(True)
+ atomic.__exit__(None, None, None)
- db_connection.close()
+ db_connection.close()
@pytest.fixture
diff --git a/plain-models/plain/models/test/utils.py b/plain-models/plain/models/test/utils.py
index 5ea3ef3dcf..8b7427f58c 100644
--- a/plain-models/plain/models/test/utils.py
+++ b/plain-models/plain/models/test/utils.py
@@ -1,11 +1,14 @@
from plain.models import db_connection
+from plain.models.otel import suppress_db_tracing
def setup_database(*, verbosity, prefix=""):
old_name = db_connection.settings_dict["NAME"]
- db_connection.creation.create_test_db(verbosity=verbosity, prefix=prefix)
+ with suppress_db_tracing():
+ db_connection.creation.create_test_db(verbosity=verbosity, prefix=prefix)
return old_name
def teardown_database(old_name, verbosity):
- db_connection.creation.destroy_test_db(old_name, verbosity)
+ with suppress_db_tracing():
+ db_connection.creation.destroy_test_db(old_name, verbosity)
diff --git a/plain-observer/LICENSE b/plain-observer/LICENSE
new file mode 100644
index 0000000000..4a29315c05
--- /dev/null
+++ b/plain-observer/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2025, Dropseed, LLC
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/plain-observer/README.md b/plain-observer/README.md
new file mode 120000
index 0000000000..5f3ab6ccef
--- /dev/null
+++ b/plain-observer/README.md
@@ -0,0 +1 @@
+plain/observer/README.md
\ No newline at end of file
diff --git a/plain-observer/plain/observer/CHANGELOG.md b/plain-observer/plain/observer/CHANGELOG.md
new file mode 100644
index 0000000000..f291ea1c34
--- /dev/null
+++ b/plain-observer/plain/observer/CHANGELOG.md
@@ -0,0 +1 @@
+# plain-observer changelog
diff --git a/plain-observer/plain/observer/README.md b/plain-observer/plain/observer/README.md
new file mode 100644
index 0000000000..a1952e884a
--- /dev/null
+++ b/plain-observer/plain/observer/README.md
@@ -0,0 +1,3 @@
+# plain.observer
+
+**Monitor.**
diff --git a/plain-observer/plain/observer/__init__.py b/plain-observer/plain/observer/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plain-observer/plain/observer/admin.py b/plain-observer/plain/observer/admin.py
new file mode 100644
index 0000000000..efa58ff792
--- /dev/null
+++ b/plain-observer/plain/observer/admin.py
@@ -0,0 +1,102 @@
+from functools import cached_property
+
+from plain.admin.toolbar import ToolbarPanel, register_toolbar_panel
+from plain.admin.views import (
+ AdminModelDetailView,
+ AdminModelListView,
+ AdminViewset,
+ register_viewset,
+)
+
+from .core import Observer
+from .models import Span, Trace
+
+
+@register_viewset
+class TraceViewset(AdminViewset):
+ class ListView(AdminModelListView):
+ nav_section = "Observer"
+ model = Trace
+ fields = [
+ "trace_id",
+ "request_id",
+ "session_id",
+ "user_id",
+ "start_time",
+ ]
+ allow_global_search = False
+ # Actually want a button to delete ALL! not possible yet
+ # actions = ["Delete"]
+
+ # def perform_action(self, action: str, target_pks: list):
+ # if action == "Delete":
+ # Trace.objects.filter(id__in=target_pks).delete()
+
+ class DetailView(AdminModelDetailView):
+ model = Trace
+ template_name = "admin/observer/trace_detail.html"
+
+ def get_template_context(self):
+ context = super().get_template_context()
+ trace_id = self.url_kwargs["pk"]
+ context["trace"] = Trace.objects.get(pk=trace_id)
+ context["show_delete_button"] = False
+ return context
+
+
+@register_viewset
+class SpanViewset(AdminViewset):
+ class ListView(AdminModelListView):
+ nav_section = "Observer"
+ model = Span
+ fields = [
+ "name",
+ "kind",
+ "status",
+ "span_id",
+ "parent_id",
+ "start_time",
+ ]
+ queryset_order = ["-pk"]
+ allow_global_search = False
+ displays = ["Parents only"]
+ search_fields = ["name", "span_id", "parent_id"]
+
+ def get_objects(self):
+ return (
+ super()
+ .get_objects()
+ .only(
+ "name",
+ "kind",
+ "span_id",
+ "parent_id",
+ "start_time",
+ )
+ )
+
+ def get_initial_queryset(self):
+ queryset = super().get_initial_queryset()
+ if self.display == "Parents only":
+ queryset = queryset.filter(parent_id="")
+ return queryset
+
+ class DetailView(AdminModelDetailView):
+ model = Span
+
+
+@register_toolbar_panel
+class ObserverToolbarPanel(ToolbarPanel):
+ name = "Observer"
+ template_name = "toolbar/observer.html"
+ button_template_name = "toolbar/observer_button.html"
+
+ @cached_property
+ def observer(self):
+ """Get the Observer instance for this request."""
+ return Observer(self.request)
+
+ def get_template_context(self):
+ context = super().get_template_context()
+ context["observer"] = self.observer
+ return context
diff --git a/plain-observer/plain/observer/cli.py b/plain-observer/plain/observer/cli.py
new file mode 100644
index 0000000000..cefb6e201b
--- /dev/null
+++ b/plain-observer/plain/observer/cli.py
@@ -0,0 +1,23 @@
+import click
+
+from plain.cli import register_cli
+from plain.observer.models import Trace
+
+
+@register_cli("observer")
+@click.group("observer")
+def observer_cli():
+ pass
+
+
+@observer_cli.command()
+@click.option("--force", is_flag=True, help="Skip confirmation prompt.")
+def clear(force: bool):
+ """Clear all observer data."""
+ if not force:
+ click.confirm(
+ "Are you sure you want to clear all observer data? This cannot be undone.",
+ abort=True,
+ )
+
+ print("Deleted", Trace.objects.all().delete())
diff --git a/plain-observer/plain/observer/config.py b/plain-observer/plain/observer/config.py
new file mode 100644
index 0000000000..413c71bdc1
--- /dev/null
+++ b/plain-observer/plain/observer/config.py
@@ -0,0 +1,36 @@
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+
+from plain.packages import PackageConfig, register_config
+
+from .otel import ObserverSampler, ObserverSpanProcessor
+
+
+@register_config
+class Config(PackageConfig):
+ package_label = "plainobserver"
+
+ def ready(self):
+ if self.has_existing_trace_provider():
+ return
+
+ self.setup_observer()
+
+ @staticmethod
+ def has_existing_trace_provider() -> bool:
+ """Check if there is an existing trace provider."""
+ current_provider = trace.get_tracer_provider()
+ return current_provider and not isinstance(
+ current_provider, trace.ProxyTracerProvider
+ )
+
+ @staticmethod
+ def setup_observer() -> None:
+ sampler = ObserverSampler()
+ provider = TracerProvider(sampler=sampler)
+
+ # Add our combined processor that handles both memory storage and export
+ observer_processor = ObserverSpanProcessor()
+ provider.add_span_processor(observer_processor)
+
+ trace.set_tracer_provider(provider)
diff --git a/plain-observer/plain/observer/core.py b/plain-observer/plain/observer/core.py
new file mode 100644
index 0000000000..8c32ecca24
--- /dev/null
+++ b/plain-observer/plain/observer/core.py
@@ -0,0 +1,63 @@
+from enum import Enum
+
+
+class ObserverMode(Enum):
+ """Observer operation modes."""
+
+ SUMMARY = "summary" # Real-time monitoring only, no DB export
+ PERSIST = "persist" # Real-time monitoring + DB export
+ DISABLED = "disabled" # Observer explicitly disabled
+
+
+class Observer:
+ """Central class for managing observer state and operations."""
+
+ COOKIE_NAME = "observer"
+ COOKIE_DURATION = 60 * 60 * 24 # 1 day in seconds
+
+ def __init__(self, request):
+ self.request = request
+
+ def mode(self):
+ """Get the current observer mode from signed cookie."""
+ return self.request.get_signed_cookie(self.COOKIE_NAME, default=None)
+
+ def is_enabled(self):
+ """Check if observer is enabled (either summary or persist mode)."""
+ return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
+
+ def is_persisting(self):
+ """Check if full persisting (with DB export) is enabled."""
+ return self.mode() == ObserverMode.PERSIST.value
+
+ def is_summarizing(self):
+ """Check if summary mode is enabled."""
+ return self.mode() == ObserverMode.SUMMARY.value
+
+ def is_disabled(self):
+ """Check if observer is explicitly disabled."""
+ return self.mode() == ObserverMode.DISABLED.value
+
+ def enable_summary_mode(self, response):
+ """Enable summary mode (real-time monitoring, no DB export)."""
+ response.set_signed_cookie(
+ self.COOKIE_NAME, ObserverMode.SUMMARY.value, max_age=self.COOKIE_DURATION
+ )
+
+ def enable_persist_mode(self, response):
+ """Enable full persist mode (real-time monitoring + DB export)."""
+ response.set_signed_cookie(
+ self.COOKIE_NAME, ObserverMode.PERSIST.value, max_age=self.COOKIE_DURATION
+ )
+
+ def disable(self, response):
+ """Disable observer by setting cookie to disabled."""
+ response.set_signed_cookie(
+ self.COOKIE_NAME, ObserverMode.DISABLED.value, max_age=self.COOKIE_DURATION
+ )
+
+ def get_current_trace_summary(self):
+ """Get performance summary string for the currently active trace."""
+ from .otel import get_current_trace_summary
+
+ return get_current_trace_summary()
diff --git a/plain-observer/plain/observer/default_settings.py b/plain-observer/plain/observer/default_settings.py
new file mode 100644
index 0000000000..2e507d6abd
--- /dev/null
+++ b/plain-observer/plain/observer/default_settings.py
@@ -0,0 +1,9 @@
+OBSERVER_IGNORE_URL_PATTERNS: list[str] = [
+ "/admin/.*",
+ "/assets/.*",
+ "/observer/.*",
+ "/pageviews/.*",
+ "/favicon.ico",
+ "/.well-known/.*",
+]
+OBSERVER_TRACE_LIMIT: int = 100
diff --git a/plain-observer/plain/observer/migrations/0001_initial.py b/plain-observer/plain/observer/migrations/0001_initial.py
new file mode 100644
index 0000000000..79be533dd8
--- /dev/null
+++ b/plain-observer/plain/observer/migrations/0001_initial.py
@@ -0,0 +1,96 @@
+# Generated by Plain 0.52.2 on 2025-07-17 02:27
+
+import plain.models.deletion
+from plain import models
+from plain.models import migrations
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Trace",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
+ ("trace_id", models.CharField(max_length=255)),
+ ("start_time", models.DateTimeField()),
+ ("end_time", models.DateTimeField()),
+ ("root_span_name", models.TextField(default="", required=False)),
+ (
+ "request_id",
+ models.CharField(default="", max_length=255, required=False),
+ ),
+ (
+ "session_id",
+ models.CharField(default="", max_length=255, required=False),
+ ),
+ (
+ "user_id",
+ models.CharField(default="", max_length=255, required=False),
+ ),
+ ],
+ options={
+ "ordering": ["-start_time"],
+ },
+ ),
+ migrations.CreateModel(
+ name="Span",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
+ ("span_id", models.CharField(max_length=255)),
+ ("name", models.CharField(max_length=255)),
+ ("kind", models.CharField(max_length=50)),
+ (
+ "parent_id",
+ models.CharField(default="", max_length=255, required=False),
+ ),
+ ("start_time", models.DateTimeField()),
+ ("end_time", models.DateTimeField()),
+ ("status", models.CharField(default="", max_length=50, required=False)),
+ ("span_data", models.JSONField(default=dict, required=False)),
+ ],
+ options={
+ "ordering": ["-start_time"],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="trace",
+ constraint=models.UniqueConstraint(
+ fields=("trace_id",), name="observer_unique_trace_id"
+ ),
+ ),
+ migrations.AddField(
+ model_name="span",
+ name="trace",
+ field=models.ForeignKey(
+ on_delete=plain.models.deletion.CASCADE,
+ related_name="spans",
+ to="plainobserver.trace",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="span",
+ index=models.Index(
+ fields=["trace", "span_id"], name="plainobserv_trace_i_89a97c_idx"
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="span",
+ index=models.Index(fields=["trace"], name="plainobserv_trace_i_84958a_idx"),
+ ),
+ migrations.AddIndex(
+ model_name="span",
+ index=models.Index(
+ fields=["start_time"], name="plainobserv_start_t_cb47a3_idx"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="span",
+ constraint=models.UniqueConstraint(
+ fields=("trace", "span_id"), name="observer_unique_span_id"
+ ),
+ ),
+ ]
diff --git a/plain-observer/plain/observer/migrations/__init__.py b/plain-observer/plain/observer/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plain-observer/plain/observer/models.py b/plain-observer/plain/observer/models.py
new file mode 100644
index 0000000000..fef33dbe03
--- /dev/null
+++ b/plain-observer/plain/observer/models.py
@@ -0,0 +1,355 @@
+import json
+from datetime import UTC, datetime
+from functools import cached_property
+
+import sqlparse
+from opentelemetry.semconv._incubating.attributes import (
+ exception_attributes,
+ session_attributes,
+ user_attributes,
+)
+from opentelemetry.semconv._incubating.attributes.db_attributes import (
+ DB_QUERY_PARAMETER_TEMPLATE,
+)
+from opentelemetry.semconv.attributes import db_attributes
+from opentelemetry.trace import format_trace_id
+
+from plain import models
+
+
+@models.register_model
+class Trace(models.Model):
+ trace_id = models.CharField(max_length=255)
+ start_time = models.DateTimeField()
+ end_time = models.DateTimeField()
+
+ root_span_name = models.TextField(default="", required=False)
+
+ # Plain fields
+ request_id = models.CharField(max_length=255, default="", required=False)
+ session_id = models.CharField(max_length=255, default="", required=False)
+ user_id = models.CharField(max_length=255, default="", required=False)
+
+ class Meta:
+ ordering = ["-start_time"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["trace_id"],
+ name="observer_unique_trace_id",
+ )
+ ]
+
+ def __str__(self):
+ return self.trace_id
+
+ def duration_ms(self):
+ return (self.end_time - self.start_time).total_seconds() * 1000
+
+ def get_trace_summary(self, spans=None):
+ """Get a concise summary string for toolbar display.
+
+ Args:
+ spans: Optional list of span objects. If not provided, will query from database.
+ """
+ # Get spans from database if not provided
+ if spans is None:
+ spans = list(self.spans.all())
+
+ if not spans:
+ return ""
+
+ # Count database queries and track duplicates
+ query_counts = {}
+ db_queries = 0
+
+ for span in spans:
+ if span.attributes.get(db_attributes.DB_SYSTEM_NAME):
+ db_queries += 1
+ if query_text := span.attributes.get(db_attributes.DB_QUERY_TEXT):
+ query_counts[query_text] = query_counts.get(query_text, 0) + 1
+
+ # Count duplicate queries (queries that appear more than once)
+ duplicate_count = sum(count - 1 for count in query_counts.values() if count > 1)
+
+ # Build summary: "n spans, n queries (n duplicates), Xms"
+ parts = []
+
+ # Queries count with duplicates
+ if db_queries > 0:
+ query_part = f"{db_queries} quer{'y' if db_queries == 1 else 'ies'}"
+ if duplicate_count > 0:
+ query_part += f" ({duplicate_count} duplicate{'' if duplicate_count == 1 else 's'})"
+ parts.append(query_part)
+
+ # Duration
+ if (duration_ms := self.duration_ms()) is not None:
+ parts.append(f"{round(duration_ms, 1)}ms")
+
+ return " • ".join(parts)
+
+ @classmethod
+ def from_opentelemetry_spans(cls, spans):
+ """Create a Trace instance from a list of OpenTelemetry spans."""
+ # Get trace information from the first span
+ first_span = spans[0]
+ trace_id = f"0x{format_trace_id(first_span.get_span_context().trace_id)}"
+
+ # Find trace boundaries and root span info
+ earliest_start = None
+ latest_end = None
+ root_span = None
+ request_id = ""
+ user_id = ""
+ session_id = ""
+
+ for span in spans:
+ if not span.parent:
+ root_span = span
+
+ if span.start_time and (
+ earliest_start is None or span.start_time < earliest_start
+ ):
+ earliest_start = span.start_time
+ # Only update latest_end if the span has actually ended
+ if span.end_time and (latest_end is None or span.end_time > latest_end):
+ latest_end = span.end_time
+
+ # For OpenTelemetry spans, access attributes directly
+ span_attrs = getattr(span, "attributes", {})
+ request_id = request_id or span_attrs.get("plain.request.id", "")
+ user_id = user_id or span_attrs.get(user_attributes.USER_ID, "")
+ session_id = session_id or span_attrs.get(session_attributes.SESSION_ID, "")
+
+ # Convert timestamps
+ start_time = (
+ datetime.fromtimestamp(earliest_start / 1_000_000_000, tz=UTC)
+ if earliest_start
+ else None
+ )
+ end_time = (
+ datetime.fromtimestamp(latest_end / 1_000_000_000, tz=UTC)
+ if latest_end
+ else None
+ )
+
+ # Create trace instance
+ # Note: end_time might be None if there are active spans
+ # This is OK since this trace is only used for summaries, not persistence
+ return cls(
+ trace_id=trace_id,
+ start_time=start_time,
+ end_time=end_time
+ or start_time, # Use start_time as fallback for active traces
+ request_id=request_id,
+ user_id=user_id,
+ session_id=session_id,
+ root_span_name=root_span.name if root_span else "",
+ )
+
+ def get_annotated_spans(self):
+ """Return spans with annotations and nesting information."""
+ spans = list(self.spans.all().order_by("start_time"))
+
+ # Build span dictionary for parent lookups
+ span_dict = {span.span_id: span for span in spans}
+
+ # Calculate nesting levels
+ for span in spans:
+ if not span.parent_id:
+ span.level = 0
+ else:
+ # Find parent's level and add 1
+ parent = span_dict.get(span.parent_id)
+ parent_level = parent.level if parent else 0
+ span.level = parent_level + 1
+
+ query_counts = {}
+
+ # First pass: count queries
+ for span in spans:
+ if sql_query := span.sql_query:
+ query_counts[sql_query] = query_counts.get(sql_query, 0) + 1
+
+ # Second pass: add annotations
+ query_occurrences = {}
+ for span in spans:
+ span.annotations = []
+
+ # Check for duplicate queries
+ if sql_query := span.sql_query:
+ count = query_counts[sql_query]
+ if count > 1:
+ occurrence = query_occurrences.get(sql_query, 0) + 1
+ query_occurrences[sql_query] = occurrence
+
+ span.annotations.append(
+ {
+ "message": f"Duplicate query ({occurrence} of {count})",
+ "severity": "warning",
+ }
+ )
+
+ return spans
+
+ def as_dict(self):
+ spans = [span.span_data for span in self.spans.all().order_by("start_time")]
+
+ return {
+ "trace_id": self.trace_id,
+ "start_time": self.start_time.isoformat(),
+ "end_time": self.end_time.isoformat(),
+ "duration_ms": self.duration_ms(),
+ "request_id": self.request_id,
+ "user_id": self.user_id,
+ "session_id": self.session_id,
+ "spans": spans,
+ }
+
+
+@models.register_model
+class Span(models.Model):
+ trace = models.ForeignKey(Trace, on_delete=models.CASCADE, related_name="spans")
+
+ span_id = models.CharField(max_length=255)
+
+ name = models.CharField(max_length=255)
+ kind = models.CharField(max_length=50)
+ parent_id = models.CharField(max_length=255, default="", required=False)
+ start_time = models.DateTimeField()
+ end_time = models.DateTimeField()
+ status = models.CharField(max_length=50, default="", required=False)
+ span_data = models.JSONField(default=dict, required=False)
+
+ class Meta:
+ ordering = ["-start_time"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["trace", "span_id"],
+ name="observer_unique_span_id",
+ )
+ ]
+ indexes = [
+ models.Index(fields=["trace", "span_id"]),
+ models.Index(fields=["trace"]),
+ models.Index(fields=["start_time"]),
+ ]
+
+ @classmethod
+ def from_opentelemetry_span(cls, otel_span, trace):
+ """Create a Span instance from an OpenTelemetry span."""
+
+ span_data = json.loads(otel_span.to_json())
+
+ # Extract status code as string, default to empty string if unset
+ status = ""
+ if span_data.get("status") and span_data["status"].get("status_code"):
+ status = span_data["status"]["status_code"]
+
+ return cls(
+ trace=trace,
+ span_id=span_data["context"]["span_id"],
+ name=span_data["name"],
+ kind=span_data["kind"][len("SpanKind.") :],
+ parent_id=span_data["parent_id"] or "",
+ start_time=span_data["start_time"],
+ end_time=span_data["end_time"],
+ status=status,
+ span_data=span_data,
+ )
+
+ def __str__(self):
+ return self.span_id
+
+ @property
+ def attributes(self):
+ """Get attributes from span_data."""
+ return self.span_data.get("attributes", {})
+
+ @property
+ def events(self):
+ """Get events from span_data."""
+ return self.span_data.get("events", [])
+
+ @property
+ def links(self):
+ """Get links from span_data."""
+ return self.span_data.get("links", [])
+
+ @property
+ def resource(self):
+ """Get resource from span_data."""
+ return self.span_data.get("resource", {})
+
+ @property
+ def context(self):
+ """Get context from span_data."""
+ return self.span_data.get("context", {})
+
+ def duration_ms(self):
+ if self.start_time and self.end_time:
+ return (self.end_time - self.start_time).total_seconds() * 1000
+ return 0
+
+ @cached_property
+ def sql_query(self):
+ """Get the SQL query if this span contains one."""
+ return self.attributes.get(db_attributes.DB_QUERY_TEXT)
+
+ @cached_property
+ def sql_query_params(self):
+ """Get query parameters from attributes that start with 'db.query.parameter.'"""
+ if not self.attributes:
+ return {}
+
+ query_params = {}
+ for key, value in self.attributes.items():
+ if key.startswith(DB_QUERY_PARAMETER_TEMPLATE + "."):
+ param_name = key.replace(DB_QUERY_PARAMETER_TEMPLATE + ".", "")
+ query_params[param_name] = value
+
+ return query_params
+
+ def get_formatted_sql(self):
+ """Get the pretty-formatted SQL query if this span contains one."""
+ sql = self.sql_query
+ if not sql:
+ return None
+
+ return sqlparse.format(
+ sql,
+ reindent=True,
+ keyword_case="upper",
+ identifier_case="lower",
+ strip_comments=False,
+ strip_whitespace=True,
+ indent_width=2,
+ wrap_after=80,
+ comma_first=False,
+ )
+
+ def format_event_timestamp(self, timestamp):
+ """Convert event timestamp to a readable datetime."""
+ if isinstance(timestamp, int | float):
+ try:
+ # Try as seconds first
+ if timestamp > 1e10: # Likely nanoseconds
+ timestamp = timestamp / 1e9
+ elif timestamp > 1e7: # Likely milliseconds
+ timestamp = timestamp / 1e3
+
+ return datetime.fromtimestamp(timestamp, tz=UTC)
+ except (ValueError, OSError):
+ return str(timestamp)
+ return timestamp
+
+ def get_exception_stacktrace(self):
+ """Get the exception stacktrace if this span has an exception event."""
+ if not self.events:
+ return None
+
+ for event in self.events:
+ if event.get("name") == "exception" and event.get("attributes"):
+ return event["attributes"].get(
+ exception_attributes.EXCEPTION_STACKTRACE
+ )
+ return None
diff --git a/plain-observer/plain/observer/otel.py b/plain-observer/plain/observer/otel.py
new file mode 100644
index 0000000000..3d50400125
--- /dev/null
+++ b/plain-observer/plain/observer/otel.py
@@ -0,0 +1,335 @@
+import logging
+import re
+import threading
+from collections import defaultdict
+
+import opentelemetry.context as context_api
+from opentelemetry import baggage, trace
+from opentelemetry.sdk.trace import SpanProcessor, sampling
+from opentelemetry.semconv.attributes import url_attributes
+from opentelemetry.trace import SpanKind, format_span_id, format_trace_id
+
+from plain.http.cookie import unsign_cookie_value
+from plain.models.otel import suppress_db_tracing
+from plain.runtime import settings
+
+from .core import Observer, ObserverMode
+
+logger = logging.getLogger(__name__)
+
+
+def get_span_processor():
+ """Get the span collector instance from the tracer provider."""
+ if not (current_provider := trace.get_tracer_provider()):
+ return None
+
+ # Look for ObserverSpanProcessor in the span processors
+ # Check if the provider has a _active_span_processor attribute
+ if hasattr(current_provider, "_active_span_processor"):
+ # It's a composite processor, check its _span_processors
+ if composite_processor := current_provider._active_span_processor:
+ if hasattr(composite_processor, "_span_processors"):
+ for processor in composite_processor._span_processors:
+ if isinstance(processor, ObserverSpanProcessor):
+ return processor
+
+ return None
+
+
+def get_current_trace_summary() -> str | None:
+ """Get performance summary for the currently active trace."""
+ if not (current_span := trace.get_current_span()):
+ return None
+
+ if not (processor := get_span_processor()):
+ return None
+
+ trace_id = f"0x{format_trace_id(current_span.get_span_context().trace_id)}"
+ return processor.get_trace_summary(trace_id)
+
+
+class ObserverSampler(sampling.Sampler):
+ """Samples traces based on request path and cookies."""
+
+ def __init__(self):
+ # Custom parent-based sampler
+ self._delegate = sampling.ParentBased(sampling.ALWAYS_OFF)
+
+ # TODO ignore url namespace instead? admin, observer, assets
+ self._ignore_url_paths = [
+ re.compile(p) for p in settings.OBSERVER_IGNORE_URL_PATTERNS
+ ]
+
+ def should_sample(
+ self,
+ parent_context,
+ trace_id,
+ name,
+ kind: SpanKind | None = None,
+ attributes=None,
+ links=None,
+ trace_state=None,
+ ):
+ # First, drop if the URL should be ignored.
+ if attributes:
+ if url_path := attributes.get(url_attributes.URL_PATH, ""):
+ for pattern in self._ignore_url_paths:
+ if pattern.match(url_path):
+ return sampling.SamplingResult(
+ sampling.Decision.DROP,
+ attributes=attributes,
+ )
+
+ # If no processor decision, check cookies directly for root spans
+ decision = None
+ if parent_context:
+ # Check cookies for sampling decision
+ if cookies := baggage.get_baggage("http.request.cookies", parent_context):
+ if observer_cookie := cookies.get(Observer.COOKIE_NAME):
+ unsigned_value = unsign_cookie_value(
+ Observer.COOKIE_NAME, observer_cookie, default=False
+ )
+
+ if unsigned_value in (
+ ObserverMode.PERSIST.value,
+ ObserverMode.SUMMARY.value,
+ ):
+ # Always use RECORD_AND_SAMPLE so ParentBased works correctly
+ # The processor will check the mode to decide whether to export
+ decision = sampling.Decision.RECORD_AND_SAMPLE
+ else:
+ decision = sampling.Decision.DROP
+
+ # If there are links, assume it is to another trace/span that we are keeping
+ if links:
+ decision = sampling.Decision.RECORD_AND_SAMPLE
+
+ # If no decision from cookies, use default
+ if decision is None:
+ result = self._delegate.should_sample(
+ parent_context,
+ trace_id,
+ name,
+ kind=kind,
+ attributes=attributes,
+ links=links,
+ trace_state=trace_state,
+ )
+ decision = result.decision
+
+ return sampling.SamplingResult(
+ decision,
+ attributes=attributes,
+ )
+
+ def get_description(self) -> str:
+ return "ObserverSampler"
+
+
+class ObserverSpanProcessor(SpanProcessor):
+ """Collects spans in real-time for current trace performance monitoring.
+
+ This processor keeps spans in memory for traces that have the 'summary' or 'persist'
+ cookie set. These spans can be accessed via get_current_trace_summary() for
+ real-time debugging. Spans with 'persist' cookie will also be persisted to the
+ database.
+ """
+
+ def __init__(self):
+ # Span storage
+ self._traces = defaultdict(
+ lambda: {
+ "trace": None, # Trace model instance
+ "active_otel_spans": {}, # span_id -> opentelemetry span
+ "completed_otel_spans": [], # list of opentelemetry spans
+ "span_models": [], # list of Span model instances
+ "root_span_id": None,
+ "mode": None, # None, ObserverMode.SUMMARY.value, or ObserverMode.PERSIST.value
+ }
+ )
+ self._traces_lock = threading.Lock()
+
+ def on_start(self, span, parent_context=None):
+ """Called when a span starts."""
+ trace_id = f"0x{format_trace_id(span.get_span_context().trace_id)}"
+
+ with self._traces_lock:
+ # Check if we already have this trace
+ if trace_id in self._traces:
+ trace_info = self._traces[trace_id]
+ else:
+ # First span in trace - determine if we should record it
+ mode = self._get_recording_mode(span, parent_context)
+ if not mode:
+ # Don't create trace entry for traces we won't record
+ return
+
+ # Create trace entry only for traces we'll record
+ trace_info = self._traces[trace_id]
+ trace_info["mode"] = mode
+
+ # Clean up old traces if too many
+ if len(self._traces) > 1000:
+ # Remove oldest 100 traces
+ oldest_ids = sorted(self._traces.keys())[:100]
+ for old_id in oldest_ids:
+ del self._traces[old_id]
+
+ span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
+
+ # Store span (we know mode is truthy if we get here)
+ trace_info["active_otel_spans"][span_id] = span
+
+ # Track root span
+ if not span.parent:
+ trace_info["root_span_id"] = span_id
+
+ def on_end(self, span):
+ """Called when a span ends."""
+ trace_id = f"0x{format_trace_id(span.get_span_context().trace_id)}"
+ span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
+
+ with self._traces_lock:
+ # Skip if we don't have this trace (mode was None on start)
+ if trace_id not in self._traces:
+ return
+
+ trace_info = self._traces[trace_id]
+
+ # Move span from active to completed
+ if trace_info["active_otel_spans"].pop(span_id, None):
+ trace_info["completed_otel_spans"].append(span)
+
+ # Check if trace is complete (root span ended)
+ if span_id == trace_info["root_span_id"]:
+ all_spans = trace_info["completed_otel_spans"]
+
+ from .models import Span, Trace
+
+ trace_info["trace"] = Trace.from_opentelemetry_spans(all_spans)
+ trace_info["span_models"] = [
+ Span.from_opentelemetry_span(s, trace_info["trace"])
+ for s in all_spans
+ ]
+
+ # Export if in persist mode
+ if trace_info["mode"] == ObserverMode.PERSIST.value:
+ logger.debug(
+ "Exporting %d spans for trace %s",
+ len(trace_info["span_models"]),
+ trace_id,
+ )
+ self._export_trace(trace_info["trace"], trace_info["span_models"])
+
+ # Clean up trace
+ del self._traces[trace_id]
+
+ def get_trace_summary(self, trace_id: str) -> str | None:
+ """Get performance summary for a specific trace."""
+ from .models import Span, Trace
+
+ with self._traces_lock:
+ # Return None if trace doesn't exist (mode was None)
+ if trace_id not in self._traces:
+ return None
+
+ trace_info = self._traces[trace_id]
+
+ # Combine active and completed spans
+ all_otel_spans = (
+ list(trace_info["active_otel_spans"].values())
+ + trace_info["completed_otel_spans"]
+ )
+
+ if not all_otel_spans:
+ return None
+
+ # Create or update trace model instance
+ if not trace_info["trace"]:
+ trace_info["trace"] = Trace.from_opentelemetry_spans(all_otel_spans)
+
+ if not trace_info["trace"]:
+ return None
+
+ # Create span model instances if needed
+ span_models = trace_info.get("span_models", [])
+ if not span_models:
+ span_models = [
+ Span.from_opentelemetry_span(s, trace_info["trace"])
+ for s in all_otel_spans
+ ]
+
+ return trace_info["trace"].get_trace_summary(span_models)
+
+ def _export_trace(self, trace, span_models):
+ """Export trace and spans to the database."""
+ from .models import Span, Trace
+
+ with suppress_db_tracing():
+ try:
+ trace.save()
+
+ for span_model in span_models:
+ span_model.trace = trace
+
+ # Bulk create spans
+ Span.objects.bulk_create(span_models)
+ except Exception as e:
+ logger.warning(
+ "Failed to export trace to database: %s",
+ e,
+ exc_info=True,
+ )
+
+ # Delete oldest traces if we exceed the limit
+ if settings.OBSERVER_TRACE_LIMIT > 0:
+ try:
+ if Trace.objects.count() > settings.OBSERVER_TRACE_LIMIT:
+ delete_ids = Trace.objects.order_by("start_time")[
+ : settings.OBSERVER_TRACE_LIMIT
+ ].values_list("id", flat=True)
+ Trace.objects.filter(id__in=delete_ids).delete()
+ except Exception as e:
+ logger.warning(
+ "Failed to clean up old observer traces: %s", e, exc_info=True
+ )
+
+ def _get_recording_mode(self, span, parent_context) -> str | None:
+ # If the span has links, then we are going to export if the linked span is also exported
+ for link in span.links:
+ if link.context.is_valid and link.context.span_id:
+ from .models import Span
+
+ if Span.objects.filter(
+ span_id=f"0x{format_span_id(link.context.span_id)}"
+ ).exists():
+ return ObserverMode.PERSIST.value
+
+ if not (context := parent_context or context_api.get_current()):
+ return None
+
+ if not (cookies := baggage.get_baggage("http.request.cookies", context)):
+ return None
+
+ if not (observer_cookie := cookies.get(Observer.COOKIE_NAME)):
+ return None
+
+ try:
+ mode = unsign_cookie_value(
+ Observer.COOKIE_NAME, observer_cookie, default=None
+ )
+ if mode in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value):
+ return mode
+ except Exception as e:
+ logger.warning("Failed to unsign observer cookie: %s", e)
+
+ return None
+
+ def shutdown(self):
+ """Cleanup when shutting down."""
+ with self._traces_lock:
+ self._traces.clear()
+
+ def force_flush(self, timeout_millis=None):
+ """Required by SpanProcessor interface."""
+ return True
diff --git a/plain-observer/plain/observer/templates/admin/observer/trace_detail.html b/plain-observer/plain/observer/templates/admin/observer/trace_detail.html
new file mode 100644
index 0000000000..5a936e8aca
--- /dev/null
+++ b/plain-observer/plain/observer/templates/admin/observer/trace_detail.html
@@ -0,0 +1,10 @@
+{% extends "admin/detail.html" %}
+
+{% block content %}
+
+{{ super() }}
+
+
+ {% include "observer/_trace_detail.html" %}
+
+{% endblock %}
diff --git a/plain-observer/plain/observer/templates/observer/_trace_detail.html b/plain-observer/plain/observer/templates/observer/_trace_detail.html
new file mode 100644
index 0000000000..ba45927bfc
--- /dev/null
+++ b/plain-observer/plain/observer/templates/observer/_trace_detail.html
@@ -0,0 +1,364 @@
+
+
+
{{ trace.root_span_name }}
+
+ {{ trace.start_time|localtime|strftime("%b %-d, %-I:%M %p") }} • {{ "%.1f"|format(trace.duration_ms() or 0) }}ms
+
+
+
+
+
+
+
+
+
+ {% if show_delete_button|default(true) %}
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+ Trace ID: {{ trace.trace_id }}
+
+ {% if trace.request_id %}
+
+ Request: {{ trace.request_id }}
+
+ {% endif %}
+ {% if trace.user_id %}
+
+ User: {{ trace.user_id }}
+
+ {% endif %}
+ {% if trace.session_id %}
+
+ Session: {{ trace.session_id }}
+
+ {% endif %}
+
+
+
+
+ {% for span in trace.get_annotated_spans() %}
+
+
+ {% set span_start_offset = ((span.start_time - trace.start_time).total_seconds() * 1000) %}
+ {% set start_percent = (span_start_offset / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
+ {% set width_percent = (span.duration_ms() / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
+
+
+
+
+
+
+
+
+
+ {{ span.start_time|localtime|strftime("%-I:%M:%S %p") }}
+
+
{{ span.name }}
+
+ {% if span.annotations %}
+
+ {% for annotation in span.annotations %}
+
+ !
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
+ {{ "%.2f"|format(span.duration_ms()) }}ms
+
+
+
+
+
+
+ {% if span.sql_query %}
+
+
+
+
+
+
+
+
+
+ Database Query
+
+ {% if span.annotations %}
+
+ {% for annotation in span.annotations %}
+
+ {{ annotation.message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
{{ span.get_formatted_sql() }}
+
+ {% if span.sql_query_params %}
+
+
Query Parameters
+
+ {% for param_key, param_value in span.sql_query_params.items() %}
+
+ {{ param_key }}:
+ {{ param_value }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% endif %}
+
+ {% if span.get_exception_stacktrace() %}
+
+
+
+
+
+
+ Exception Stacktrace
+
+
+
+
{{ span.get_exception_stacktrace() }}
+
+
+ {% endif %}
+
+
+
+
Basic Information
+
+
+ ID:
+ {{ span.span_id }}
+
+
+ Name:
+ {{ span.name }}
+
+
+ Kind:
+
+ {{ span.kind }}
+
+
+
+ Duration:
+ {{ "%.2f"|format(span.duration_ms() or 0) }}ms
+
+ {% if span.parent_id %}
+
+ Parent:
+ {{ span.parent_id }}
+
+ {% endif %}
+
+
+
+
+
Timing
+
+
+ Started:
+ {{ span.start_time|localtime|strftime("%-I:%M:%S.%f %p") }}
+
+
+ Ended:
+ {{ span.end_time|localtime|strftime("%-I:%M:%S.%f %p") }}
+
+ {% if span.status and span.status != '' and span.status != 'UNSET' %}
+
+ Status:
+
+ {{ span.status }}
+
+
+ {% endif %}
+
+
+
+
+ {% if span.attributes %}
+
+
Attributes
+
+
+ {% for key, value in span.attributes.items() %}
+
+ {{ key }}:
+ {{ value }}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if span.events %}
+
+
Events ({{ span.events|length }})
+
+
+ {% for event in span.events %}
+
+
+
{{ event.name }}
+
+ {% set formatted_time = span.format_event_timestamp(event.timestamp) %}
+ {% if formatted_time.__class__.__name__ == 'datetime' %}
+ {{ formatted_time|localtime|strftime("%-I:%M:%S.%f %p") }}
+ {% else %}
+ {{ formatted_time }}
+ {% endif %}
+
+
+ {% if event.attributes %}
+
+ {% for key, value in event.attributes.items() %}
+
+
{{ key }}:
+
{{ value }}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if span.links %}
+
+
Links ({{ span.links|length }})
+
+
+ {% for link in span.links %}
+
+
{{ link.context.trace_id }}
+
{{ link.context.span_id }}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+ {% else %}
+
No spans...
+ {% endfor %}
+
+
+
+
+
diff --git a/plain-observer/plain/observer/templates/observer/traces.html b/plain-observer/plain/observer/templates/observer/traces.html
new file mode 100644
index 0000000000..ee752a0d7f
--- /dev/null
+++ b/plain-observer/plain/observer/templates/observer/traces.html
@@ -0,0 +1,288 @@
+
+
+
+
+
+
Querystats
+ {% tailwind_css %}
+ {% htmx_js %}
+
+
+
+
+ {% if traces %}
+
+
+
+
+ {% htmxfragment "trace" %}
+ {% set show_delete_button = True %}
+ {% include "observer/_trace_detail.html" %}
+ {% endhtmxfragment %}
+
+
+ {% elif observer.is_enabled() %}
+
+
+
+
+
+
+ {% if observer.is_summarizing() %}
+
+
+
+
+ {% else %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% if observer.is_summarizing() %}
+ Toolbar Summary Only
+ {% else %}
+ Recording Traces
+ {% endif %}
+
+
+ {% if observer.is_summarizing() %}
+ Performance summary is displayed in real-time. No traces are being stored.
+ {% else %}
+ Waiting for requests... Traces will appear here automatically.
+ {% endif %}
+
+
+
+
+ {% if observer.is_summarizing() %}
+
+ {% elif observer.is_persisting() %}
+
+
+
+
+
+ Check for Traces
+ Checking...
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% else %}
+
+
+
+
+
+
+
+
+
Observer is Disabled
+
+ Enable observer to start monitoring your application's performance and traces.
+
+
+
+
+
+
+
+
+
+
+
+ Summary Mode
+
+
Monitor performance in real-time without saving traces.
+
+
+
+
+
+
+ Recording Mode
+
+
Record and store traces for detailed analysis.
+
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
diff --git a/plain-admin/plain/admin/templates/toolbar/querystats.html b/plain-observer/plain/observer/templates/toolbar/observer.html
similarity index 56%
rename from plain-admin/plain/admin/templates/toolbar/querystats.html
rename to plain-observer/plain/observer/templates/toolbar/observer.html
index bc8c10d40d..6a7640ef45 100644
--- a/plain-admin/plain/admin/templates/toolbar/querystats.html
+++ b/plain-observer/plain/observer/templates/toolbar/observer.html
@@ -1,19 +1,33 @@
-
+
-
Loading querystats...
+
Loading spans...