diff --git a/demos/full/app/settings.py b/demos/full/app/settings.py index 5ffe528d61..5747eb5ec4 100644 --- a/demos/full/app/settings.py +++ b/demos/full/app/settings.py @@ -21,6 +21,7 @@ "plain.tailwind", "plain.worker", "plain.redirection", + "plain.observer", "app.users", ] diff --git a/demos/full/pyproject.toml b/demos/full/pyproject.toml index 13028503dd..6b0aa9962d 100644 --- a/demos/full/pyproject.toml +++ b/demos/full/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "plain-tunnel", "plain-vendor", "plain-worker", + "plain-observer", ] [tool.plain.tailwind] diff --git a/plain-admin/plain/admin/default_settings.py b/plain-admin/plain/admin/default_settings.py index 10b607d1a0..70bad53c3f 100644 --- a/plain-admin/plain/admin/default_settings.py +++ b/plain-admin/plain/admin/default_settings.py @@ -1,8 +1,2 @@ ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar" ADMIN_TOOLBAR_VERSION: str = "dev" - -ADMIN_QUERYSTATS_IGNORE_URLS: list[str] = [ - "/assets/.*", - "/admin/querystats/.*", - "/favicon.ico", -] diff --git a/plain-admin/plain/admin/middleware.py b/plain-admin/plain/admin/middleware.py index f7e57cde45..a0d817c83f 100644 --- a/plain-admin/plain/admin/middleware.py +++ b/plain-admin/plain/admin/middleware.py @@ -1,5 +1,4 @@ from .impersonate.middleware import ImpersonateMiddleware -from .querystats.middleware import QueryStatsMiddleware class AdminMiddleware: @@ -9,4 +8,4 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - return QueryStatsMiddleware(ImpersonateMiddleware(self.get_response))(request) + return ImpersonateMiddleware(self.get_response)(request) diff --git a/plain-admin/plain/admin/querystats/README.md b/plain-admin/plain/admin/querystats/README.md deleted file mode 100644 index 853dfa7162..0000000000 --- a/plain-admin/plain/admin/querystats/README.md +++ /dev/null @@ -1,146 +0,0 @@ -# plain.querystats - -On-page database query stats in development and production. - -On each page, the query stats will display how many database queries were performed and how long they took. - -[Watch on YouTube](https://www.youtube.com/watch?v=NX8VXxVJm08) - -Clicking the stats in the toolbar will show the full SQL query log with tracebacks and timings. -This is even designed to work in production, -making it much easier to discover and debug performance issues on production data! - -![Django query stats](https://user-images.githubusercontent.com/649496/213781593-54197bb6-36a8-4c9d-8294-5b43bd86a4c9.png) - -It will also point out duplicate queries, -which can typically be removed by using `select_related`, -`prefetch_related`, or otherwise refactoring your code. - -## Installation - -```python -# settings.py -INSTALLED_PACKAGES = [ - # ... - "plain.admin.querystats", -] - -MIDDLEWARE = [ - "plain.sessions.middleware.SessionMiddleware", - "plain.auth.middleware.AuthenticationMiddleware", - - "plain.admin.querystats.QueryStatsMiddleware", - # Put additional middleware below querystats - # ... -] -``` - -We strongly recommend using the plain-toolbar along with this, -but if you aren't, -you can add the querystats to your frontend templates with this include: - -```html -{% include "querystats/button.html" %} -``` - -_Note that you will likely want to surround this with an if `DEBUG` or `is_admin` check._ - -To view querystats you need to send a POST request to `?querystats=store` (i.e. via a `
`), -and the template include is the easiest way to do that. - -## Tailwind CSS - -This package is styled with [Tailwind CSS](https://tailwindcss.com/), -and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind). - -If you are using your own Tailwind implementation, -you can modify the "content" in your Tailwind config to include any Plain packages: - -```js -// tailwind.config.js -module.exports = { - content: [ - // ... - ".venv/lib/python*/site-packages/plain*/**/*.{html,js}", - ], - // ... -} -``` - -If you aren't using Tailwind, and don't intend to, open an issue to discuss other options. - -# plain.toolbar - -The admin toolbar is enabled for every user who `is_admin`. - -![Plain admin toolbar](https://user-images.githubusercontent.com/649496/213781915-a2094f54-99b8-4a05-a36e-dee107405229.png) - -## Installation - -Add `plaintoolbar` to your `INSTALLED_PACKAGES`, -and the `{% toolbar %}` to your base template: - -```python -# settings.py -INSTALLED_PACKAGES += [ - "plaintoolbar", -] -``` - -```html - -{% load toolbar %} - - - - ... - - - {% toolbar %} - ... - -``` - -More specific settings can be found below. - -## Tailwind CSS - -This package is styled with [Tailwind CSS](https://tailwindcss.com/), -and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind). - -If you are using your own Tailwind implementation, -you can modify the "content" in your Tailwind config to include any Plain packages: - -```js -// tailwind.config.js -module.exports = { - content: [ - // ... - ".venv/lib/python*/site-packages/plain*/**/*.{html,js}", - ], - // ... -} -``` - -If you aren't using Tailwind, and don't intend to, open an issue to discuss other options. - -## Tailwind CSS - -This package is styled with [Tailwind CSS](https://tailwindcss.com/), -and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind). - -If you are using your own Tailwind implementation, -you can modify the "content" in your Tailwind config to include any Plain packages: - -```js -// tailwind.config.js -module.exports = { - content: [ - // ... - ".venv/lib/python*/site-packages/plain*/**/*.{html,js}", - ], - // ... -} -``` - -If you aren't using Tailwind, and don't intend to, open an issue to discuss other options. diff --git a/plain-admin/plain/admin/querystats/__init__.py b/plain-admin/plain/admin/querystats/__init__.py deleted file mode 100644 index 0d8edc7a76..0000000000 --- a/plain-admin/plain/admin/querystats/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .middleware import QueryStatsMiddleware - -__all__ = ["QueryStatsMiddleware"] diff --git a/plain-admin/plain/admin/querystats/core.py b/plain-admin/plain/admin/querystats/core.py deleted file mode 100644 index 90768b0b6b..0000000000 --- a/plain-admin/plain/admin/querystats/core.py +++ /dev/null @@ -1,155 +0,0 @@ -import datetime -import time -import traceback -from collections import Counter -from functools import cached_property - -import sqlparse - -IGNORE_STACK_FILES = [ - "threading", - "concurrent/futures", - "functools.py", - "socketserver", - "wsgiref", - "gunicorn", - "whitenoise", - "sentry_sdk", - "querystats/core", - "plain/template/base", - "plain/models", - "plain/internal", -] - - -def pretty_print_sql(sql): - return sqlparse.format(sql, reindent=True, keyword_case="upper") - - -def get_stack(): - return "".join(tidy_stack(traceback.format_stack())) - - -def tidy_stack(stack): - lines = [] - - skip_next = False - - for line in stack: - if skip_next: - skip_next = False - continue - - if line.startswith(' File "') and any( - ignore in line for ignore in IGNORE_STACK_FILES - ): - skip_next = True - continue - - lines.append(line) - - return lines - - -class QueryStats: - def __init__(self, include_tracebacks): - self.queries = [] - self.include_tracebacks = include_tracebacks - - def __str__(self): - s = f"{self.num_queries} queries in {self.total_time_display}" - if self.duplicate_queries: - s += f" ({self.num_duplicate_queries} duplicates)" - return s - - def __call__(self, execute, sql, params, many, context): - current_query = {"sql": sql, "params": params, "many": many} - start = time.monotonic() - - result = execute(sql, params, many, context) - - if self.include_tracebacks: - current_query["tb"] = get_stack() - - # if many, then X times is len(params) - - # current_query["result"] = result - - current_query["duration"] = time.monotonic() - start - - self.queries.append(current_query) - return result - - @cached_property - def total_time(self): - return sum(q["duration"] for q in self.queries) - - @staticmethod - def get_time_display(seconds): - if seconds < 0.01: - return f"{seconds * 1000:.0f} ms" - return f"{seconds:.2f} seconds" - - @cached_property - def total_time_display(self): - return self.get_time_display(self.total_time) - - @cached_property - def num_queries(self): - return len(self.queries) - - # @cached_property - # def models(self): - # # parse table names from self.queries sql - # table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x] - # models = connection.introspection.installed_models(table_names) - # return models - - @cached_property - def duplicate_queries(self): - sqls = [q["sql"] for q in self.queries] - duplicates = {k: v for k, v in Counter(sqls).items() if v > 1} - return duplicates - - @cached_property - def num_duplicate_queries(self): - # Count the number of "excess" queries by getting how many there - # are minus the initial one (and potentially only one required) - return sum(self.duplicate_queries.values()) - len(self.duplicate_queries) - - def as_summary_dict(self): - return { - "summary": str(self), - "total_time": self.total_time, - "num_queries": self.num_queries, - "num_duplicate_queries": self.num_duplicate_queries, - } - - def as_context_dict(self, request): - # If we don't create a dict, the instance of this class - # is lost before we can use it in the template - for query in self.queries: - # Add some useful display info - query["duration_display"] = self.get_time_display(query["duration"]) - query["sql_display"] = pretty_print_sql(query["sql"]) - duplicates = self.duplicate_queries.get(query["sql"], 0) - if duplicates: - query["duplicate_count"] = duplicates - - return { - **self.as_summary_dict(), - "request": { - "path": request.path, - "method": request.method, - "unique_id": request.unique_id, - }, - "timestamp": datetime.datetime.now().isoformat(), - "total_time_display": self.total_time_display, - "queries": self.queries, - } - - def as_server_timing(self): - duration = self.total_time * 1000 # put in ms - duration = round(duration, 2) - description = str(self) - return f'querystats;dur={duration};desc="{description}"' diff --git a/plain-admin/plain/admin/querystats/middleware.py b/plain-admin/plain/admin/querystats/middleware.py deleted file mode 100644 index 0dfe8f9d85..0000000000 --- a/plain-admin/plain/admin/querystats/middleware.py +++ /dev/null @@ -1,102 +0,0 @@ -import json -import logging -import re - -from plain.json import PlainJSONEncoder -from plain.models import db_connection -from plain.runtime import settings - -from .core import QueryStats - -try: - import psycopg -except ImportError: - psycopg = None - -logger = logging.getLogger(__name__) - - -class QueryStatsJSONEncoder(PlainJSONEncoder): - def default(self, obj): - try: - return super().default(obj) - except TypeError: - if psycopg and isinstance(obj, psycopg.types.json.Json): - return obj.obj - elif psycopg and isinstance(obj, psycopg.types.json.Jsonb): - return obj.obj - else: - raise - - -class QueryStatsMiddleware: - def __init__(self, get_response): - self.get_response = get_response - self.ignore_url_patterns = [ - re.compile(url) for url in settings.ADMIN_QUERYSTATS_IGNORE_URLS - ] - - def should_ignore_request(self, request): - for url in self.ignore_url_patterns: - if url.match(request.path): - return True - - return False - - def __call__(self, request): - """ - Enables querystats for the current request. - - If DEBUG or an admin, then Server-Timing headers are always added to the response. - Full querystats are only stored in the session if they are manually enabled. - """ - - if self.should_ignore_request(request): - return self.get_response(request) - - def is_tracking(): - return "querystats" in request.session - - querystats = QueryStats(include_tracebacks=is_tracking()) - - with db_connection.execute_wrapper(querystats): - is_admin = self.is_admin_request(request) - - if settings.DEBUG or is_admin: - with db_connection.execute_wrapper(querystats): - response = self.get_response(request) - - if settings.DEBUG: - # TODO logging settings - logger.debug("Querystats: %s", querystats) - - # Make current querystats available on the current page - # by using the server timing API which can be parsed client-side - response.headers["Server-Timing"] = querystats.as_server_timing() - - if is_tracking() and querystats.num_queries > 0: - request.session["querystats"][request.unique_id] = json.dumps( - querystats.as_context_dict(request), cls=QueryStatsJSONEncoder - ) - - # Keep 30 requests max, in case it is left on by accident - if len(request.session["querystats"]) > 30: - del request.session["querystats"][ - list(request.session["querystats"])[0] - ] - - # Did a deeper modification to the session dict... - request.session.modified = True - - return response - - else: - return self.get_response(request) - - @staticmethod - def is_admin_request(request): - if getattr(request, "impersonator", None): - # Support for impersonation (still want the real admin user to see the querystats) - return request.impersonator and request.impersonator.is_admin - - return hasattr(request, "user") and request.user and request.user.is_admin diff --git a/plain-admin/plain/admin/querystats/urls.py b/plain-admin/plain/admin/querystats/urls.py deleted file mode 100644 index 4b5e359468..0000000000 --- a/plain-admin/plain/admin/querystats/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from plain.urls import Router, path - -from . import views - - -class QuerystatsRouter(Router): - namespace = "querystats" - urls = [ - path("", views.QuerystatsView, name="querystats"), - ] diff --git a/plain-admin/plain/admin/querystats/views.py b/plain-admin/plain/admin/querystats/views.py deleted file mode 100644 index c7715e2f49..0000000000 --- a/plain-admin/plain/admin/querystats/views.py +++ /dev/null @@ -1,74 +0,0 @@ -import json - -from plain.auth.views import AuthViewMixin -from plain.http import ResponseRedirect -from plain.runtime import settings -from plain.views import TemplateView - - -class QuerystatsView(AuthViewMixin, TemplateView): - template_name = "querystats/querystats.html" - admin_required = True - - def check_auth(self): - # Allow the view if we're in DEBUG - if settings.DEBUG: - return - - super().check_auth() - - def get_response(self): - response = super().get_response() - # So we can load it in the toolbar - response.headers["X-Frame-Options"] = "SAMEORIGIN" - return response - - def get(self): - # Give an easy out if things get messed up - if ( - "clear" in self.request.query_params - and "querystats" in self.request.session - ): - del self.request.session["querystats"] - self.request.session.modified = True - - return super().get() - - def get_template_context(self): - context = super().get_template_context() - - querystats = self.request.session.get("querystats", {}) - - for request_id in list(querystats.keys()): - try: - querystats[request_id] = json.loads(querystats[request_id]) - except (json.JSONDecodeError, TypeError): - # If decoding fails, remove the entry from the dictionary - del querystats[request_id] - - # Order them by timestamp - querystats = dict( - sorted( - querystats.items(), - key=lambda item: item[1].get("timestamp", ""), - reverse=True, - ) - ) - - context["querystats"] = querystats - context["querystats_enabled"] = "querystats" in self.request.session - - return context - - def post(self): - querystats_action = self.request.data["querystats_action"] - - if querystats_action == "enable": - self.request.session.setdefault("querystats", {}) - elif querystats_action == "clear": - self.request.session["querystats"] = {} - elif querystats_action == "disable" and "querystats" in self.request.session: - del self.request.session["querystats"] - - # Redirect back to the page that submitted the form - return ResponseRedirect(self.request.data.get("redirect_url", ".")) diff --git a/plain-admin/plain/admin/templates/querystats/querystats.html b/plain-admin/plain/admin/templates/querystats/querystats.html deleted file mode 100644 index 886b72c7c6..0000000000 --- a/plain-admin/plain/admin/templates/querystats/querystats.html +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - Querystats - {% tailwind_css %} - - - - {% if querystats_enabled %} -
- -
-
- - {{ csrf_input }} - - -
- {{ csrf_input }} - - -
-
- {{ csrf_input }} - - -
-
-
- {% endif %} - - {% if querystats %} -
- - -
- {% for request_id, qs in querystats.items() %} - - {% endfor %} -
-
- {% elif querystats_enabled %} -
Querystats are enabled but nothing has been recorded yet.
- {% else %} -
-
Querystats are disabled.
-
- {{ csrf_input }} - - -
-
- {% endif %} - - - - - diff --git a/plain-admin/plain/admin/templates/querystats/toolbar.html b/plain-admin/plain/admin/templates/querystats/toolbar.html deleted file mode 100644 index 85ccf60cd1..0000000000 --- a/plain-admin/plain/admin/templates/querystats/toolbar.html +++ /dev/null @@ -1,90 +0,0 @@ - diff --git a/plain-admin/plain/admin/templates/toolbar/exception_button.html b/plain-admin/plain/admin/templates/toolbar/exception_button.html new file mode 100644 index 0000000000..92a92f8aca --- /dev/null +++ b/plain-admin/plain/admin/templates/toolbar/exception_button.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plain-admin/plain/admin/templates/toolbar/toolbar.html b/plain-admin/plain/admin/templates/toolbar/toolbar.html index dbeb6803bf..4040dc2dee 100644 --- a/plain-admin/plain/admin/templates/toolbar/toolbar.html +++ b/plain-admin/plain/admin/templates/toolbar/toolbar.html @@ -58,17 +58,13 @@
- {% include "querystats/toolbar.html" %} -
- {% 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() %} +
+ {{ csrf_input }} + + +
+ {% elif observer.is_persisting() %} + +
+ {{ csrf_input }} + + +
+ {% endif %} + + +
+ {{ csrf_input }} + + +
+
+
+
+
+
+ {% 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.

+
+
+
+ + +
+
+ {{ csrf_input }} + + +
+
+ {{ csrf_input }} + + +
+
+
+
+
+
+ {% 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...