plain 0.53.0__py3-none-any.whl → 0.54.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.54.0](https://github.com/dropseed/plain/releases/plain@0.54.0) (2025-07-18)
4
+
5
+ ### What's changed
6
+
7
+ - Added OpenTelemetry instrumentation for HTTP requests, views, and template rendering ([b0224d0418](https://github.com/dropseed/plain/commit/b0224d0418))
8
+ - Added `plain-observer` package reference to plain README ([f29ff4dafe](https://github.com/dropseed/plain/commit/f29ff4dafe))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
3
14
  ## [0.53.0](https://github.com/dropseed/plain/releases/plain@0.53.0) (2025-07-18)
4
15
 
5
16
  ### What's changed
plain/README.md CHANGED
@@ -48,6 +48,7 @@ The `plain` package includes everything you need to start handling web requests
48
48
  - [plain.support](/plain-support/plain/support/README.md) - Customer support forms.
49
49
  - [plain.redirection](/plain-redirection/plain/redirection/README.md) - Redirects managed in the database.
50
50
  - [plain.pageviews](/plain-pageviews/plain/pageviews/README.md) - Basic self-hosted page view tracking and reporting.
51
+ - [plain.observer](/plain-observer/plain/observer/README.md) - On-page telemetry reporting.
51
52
 
52
53
  ## Dev Packages
53
54
 
@@ -1,6 +1,9 @@
1
1
  import logging
2
2
  import types
3
3
 
4
+ from opentelemetry import baggage, trace
5
+ from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
+
4
7
  from plain.exceptions import ImproperlyConfigured
5
8
  from plain.logs import log_response
6
9
  from plain.runtime import settings
@@ -26,6 +29,9 @@ BUILTIN_AFTER_MIDDLEWARE = [
26
29
  ]
27
30
 
28
31
 
32
+ tracer = trace.get_tracer("plain")
33
+
34
+
29
35
  class BaseHandler:
30
36
  _middleware_chain = None
31
37
 
@@ -35,8 +41,7 @@ class BaseHandler:
35
41
 
36
42
  Must be called after the environment is fixed (see __call__ in subclasses).
37
43
  """
38
- get_response = self._get_response
39
- handler = convert_exception_to_response(get_response)
44
+ handler = convert_exception_to_response(self._get_response)
40
45
 
41
46
  middlewares = reversed(
42
47
  BUILTIN_BEFORE_MIDDLEWARE + settings.MIDDLEWARE + BUILTIN_AFTER_MIDDLEWARE
@@ -59,18 +64,55 @@ class BaseHandler:
59
64
 
60
65
  def get_response(self, request):
61
66
  """Return a Response object for the given HttpRequest."""
62
- # Setup default url resolver for this thread
63
- response = self._middleware_chain(request)
64
- response._resource_closers.append(request.close)
65
- if response.status_code >= 400:
66
- log_response(
67
- "%s: %s",
68
- response.reason_phrase,
69
- request.path,
70
- response=response,
71
- request=request,
67
+
68
+ span_attributes = {
69
+ "plain.request.id": request.unique_id,
70
+ http_attributes.HTTP_REQUEST_METHOD: request.method,
71
+ url_attributes.URL_PATH: request.path_info,
72
+ url_attributes.URL_SCHEME: request.scheme,
73
+ }
74
+
75
+ # Add full URL if we can build it (requires proper WSGI environment)
76
+ try:
77
+ span_attributes[url_attributes.URL_FULL] = request.build_absolute_uri()
78
+ except KeyError:
79
+ # Missing required WSGI environment variables (e.g. in tests)
80
+ pass
81
+
82
+ # Add query string if present
83
+ if query_string := request.meta.get("QUERY_STRING"):
84
+ span_attributes[url_attributes.URL_QUERY] = query_string
85
+
86
+ span_context = baggage.set_baggage("http.request.cookies", request.cookies)
87
+
88
+ with tracer.start_as_current_span(
89
+ f"{request.method} {request.path_info}",
90
+ context=span_context,
91
+ attributes=span_attributes,
92
+ kind=trace.SpanKind.SERVER,
93
+ ) as span:
94
+ response = self._middleware_chain(request)
95
+ response._resource_closers.append(request.close)
96
+
97
+ span.set_attribute(
98
+ http_attributes.HTTP_RESPONSE_STATUS_CODE, response.status_code
99
+ )
100
+
101
+ span.set_status(
102
+ trace.StatusCode.OK
103
+ if response.status_code < 400
104
+ else trace.StatusCode.ERROR
72
105
  )
73
- return response
106
+
107
+ if response.status_code >= 400:
108
+ log_response(
109
+ "%s: %s",
110
+ response.reason_phrase,
111
+ request.path,
112
+ response=response,
113
+ request=request,
114
+ )
115
+ return response
74
116
 
75
117
  def _get_response(self, request):
76
118
  """
@@ -94,9 +136,18 @@ class BaseHandler:
94
136
  Retrieve/set the urlrouter for the request. Return the view resolved,
95
137
  with its args and kwargs.
96
138
  """
139
+
97
140
  resolver = get_resolver()
98
141
  # Resolve the view, and assign the match object back to the request.
99
142
  resolver_match = resolver.resolve(request.path_info)
143
+
144
+ span = trace.get_current_span()
145
+ span.set_attribute(http_attributes.HTTP_ROUTE, resolver_match.route)
146
+
147
+ # Route makes a better name
148
+ if resolver_match.route:
149
+ span.update_name(f"{request.method} {resolver_match.route}")
150
+
100
151
  request.resolver_match = resolver_match
101
152
  return resolver_match
102
153
 
plain/templates/core.py CHANGED
@@ -1,7 +1,14 @@
1
1
  import jinja2
2
+ from opentelemetry import trace
3
+ from opentelemetry.semconv._incubating.attributes.code_attributes import (
4
+ CODE_FUNCTION_NAME,
5
+ CODE_NAMESPACE,
6
+ )
2
7
 
3
8
  from .jinja import environment
4
9
 
10
+ tracer = trace.get_tracer("plain")
11
+
5
12
 
6
13
  class TemplateFileMissing(Exception):
7
14
  def __str__(self) -> str:
@@ -21,4 +28,15 @@ class Template:
21
28
  raise TemplateFileMissing(filename)
22
29
 
23
30
  def render(self, context: dict) -> str:
24
- return self._jinja_template.render(context)
31
+ with tracer.start_as_current_span(
32
+ f"render {self.filename}",
33
+ kind=trace.SpanKind.INTERNAL,
34
+ attributes={
35
+ CODE_FUNCTION_NAME: "render",
36
+ CODE_NAMESPACE: f"{self.__class__.__module__}.{self.__class__.__qualname__}",
37
+ "template.filename": self.filename,
38
+ "template.engine": "jinja2",
39
+ },
40
+ ):
41
+ result = self._jinja_template.render(context)
42
+ return result
plain/views/base.py CHANGED
@@ -1,6 +1,12 @@
1
1
  import logging
2
2
  from http import HTTPMethod
3
3
 
4
+ from opentelemetry import trace
5
+ from opentelemetry.semconv._incubating.attributes.code_attributes import (
6
+ CODE_FUNCTION_NAME,
7
+ CODE_NAMESPACE,
8
+ )
9
+
4
10
  from plain.http import (
5
11
  HttpRequest,
6
12
  JsonResponse,
@@ -16,6 +22,9 @@ from .exceptions import ResponseException
16
22
  logger = logging.getLogger("plain.request")
17
23
 
18
24
 
25
+ tracer = trace.get_tracer("plain")
26
+
27
+
19
28
  class View:
20
29
  request: HttpRequest
21
30
  url_args: tuple
@@ -35,9 +44,23 @@ class View:
35
44
  @classonlymethod
36
45
  def as_view(cls, *init_args, **init_kwargs):
37
46
  def view(request, *url_args, **url_kwargs):
38
- v = cls(*init_args, **init_kwargs)
39
- v.setup(request, *url_args, **url_kwargs)
40
- return v.get_response()
47
+ with tracer.start_as_current_span(
48
+ f"{cls.__name__}",
49
+ kind=trace.SpanKind.INTERNAL,
50
+ attributes={
51
+ CODE_FUNCTION_NAME: "as_view",
52
+ CODE_NAMESPACE: f"{cls.__module__}.{cls.__qualname__}",
53
+ },
54
+ ) as span:
55
+ v = cls(*init_args, **init_kwargs)
56
+ v.setup(request, *url_args, **url_kwargs)
57
+ response = v.get_response()
58
+ span.set_status(
59
+ trace.StatusCode.OK
60
+ if response.status_code < 400
61
+ else trace.StatusCode.ERROR
62
+ )
63
+ return response
41
64
 
42
65
  view.view_class = cls
43
66
 
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.53.0
3
+ Version: 0.54.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.11
8
8
  Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: jinja2>=3.1.2
10
+ Requires-Dist: opentelemetry-api>=1.34.1
11
+ Requires-Dist: opentelemetry-semantic-conventions>=0.55b1
10
12
  Description-Content-Type: text/markdown
11
13
 
12
14
  # Plain
@@ -59,6 +61,7 @@ The `plain` package includes everything you need to start handling web requests
59
61
  - [plain.support](/plain-support/plain/support/README.md) - Customer support forms.
60
62
  - [plain.redirection](/plain-redirection/plain/redirection/README.md) - Redirects managed in the database.
61
63
  - [plain.pageviews](/plain-pageviews/plain/pageviews/README.md) - Basic self-hosted page view tracking and reporting.
64
+ - [plain.observer](/plain-observer/plain/observer/README.md) - On-page telemetry reporting.
62
65
 
63
66
  ## Dev Packages
64
67
 
@@ -1,5 +1,5 @@
1
- plain/CHANGELOG.md,sha256=DHF0llr5trZE_8C0138MnuFQEAM9W2jM9nL0xfjjz8Y,4472
2
- plain/README.md,sha256=gik6DBZcJAITcm4WRq_L53AxkjY45eQLafyTCSf0CKE,3986
1
+ plain/CHANGELOG.md,sha256=x8vKNqfNIAeNvkBhz1648Y4zqH3MpfHACrkLW-8F9mk,4907
2
+ plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
3
3
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
4
4
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
5
5
  plain/exceptions.py,sha256=Z9cbPE5im_Y-bjVq8cqC85gBoqOr80rLFG5wTKixrwE,5894
@@ -62,7 +62,7 @@ plain/internal/files/uploadedfile.py,sha256=JRB7T3quQjg-1y3l1ASPxywtSQZhaeMc45uF
62
62
  plain/internal/files/uploadhandler.py,sha256=63_QUwAwfq3bevw79i0S7zt2EB2UBoO7MaauvezaVMY,7198
63
63
  plain/internal/files/utils.py,sha256=xN4HTJXDRdcoNyrL1dFd528MBwodRlHZM8DGTD_oBIg,2646
64
64
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
- plain/internal/handlers/base.py,sha256=6K8wd5DojOYA-UEQTd1jxdDggm5ThHXQahVcuJqd2qo,4169
65
+ plain/internal/handlers/base.py,sha256=odNSI5v5c8WsQjebPeKpKtmEreiFNQrwuG-_lM8mY3E,5882
66
66
  plain/internal/handlers/exception.py,sha256=vfha_6-fz6S6VYCP1PMBfue2Gw-_th6jqaTE372fGlw,4809
67
67
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
68
68
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -96,7 +96,7 @@ plain/signals/dispatch/dispatcher.py,sha256=VxSlqn9PCOTghPPJLOqZPs6FNQZfV2BJpMfF
96
96
  plain/signals/dispatch/license.txt,sha256=o9EhDhsC4Q5HbmD-IfNGVTEkXtNE33r5rIt3lleJ8gc,1727
97
97
  plain/templates/README.md,sha256=G43IdTRJX6mfxsExUTQf9QAwPeHLsDgY7PvKXdx4l6g,2432
98
98
  plain/templates/__init__.py,sha256=bX76FakE9T7mfK3N0deN85HlwHNQpeigytSC9Z8LcOs,451
99
- plain/templates/core.py,sha256=iw58EAmyyv8N5HDA-Sq4-fLgz_qx8v8WJfurgR116jw,625
99
+ plain/templates/core.py,sha256=mbcH0yTeFOI3XOg9dYSroXRIcdv9sETEy4HzY-ugwco,1258
100
100
  plain/templates/jinja/__init__.py,sha256=xvYif0feMYR9pWjN0czthq2dk3qI4D5UQjgj9yp4dNA,2776
101
101
  plain/templates/jinja/environments.py,sha256=9plifzvQj--aTN1cCpJ2WdzQxZJpzB8S_4hghgQRQT0,2064
102
102
  plain/templates/jinja/extensions.py,sha256=AEmmmHDbdRW8fhjYDzq9eSSNbp9WHsXenD8tPthjc0s,1351
@@ -141,7 +141,7 @@ plain/utils/timezone.py,sha256=6u0sE-9RVp0_OCe0Y1KiYYQoq5THWLokZFQYY8jf78g,6221
141
141
  plain/utils/tree.py,sha256=wdWzmfsgc26YDF2wxhAY3yVxXTixQYqYDKE9mL3L3ZY,4383
142
142
  plain/views/README.md,sha256=_jR_8_eccE1Qwc9sbUhD_hpZGGf0r-HY4W-al6kqtGs,6762
143
143
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
144
- plain/views/base.py,sha256=WVwJZ-N8zt0lMjVWffxglgORZBq6TeXiYmT3I8dP7fg,3430
144
+ plain/views/base.py,sha256=CC9UvMZeAjVvi90vGjoZzsQ0jnhbg3-7qCKQ8-Pb6cg,4184
145
145
  plain/views/csrf.py,sha256=7q6l5xzLWhRnMY64aNj0hR6G-3pxI2yhRwG6k_5j00E,144
146
146
  plain/views/errors.py,sha256=jbNCJIzowwCsEvqyJ3opMeZpPDqTyhtrbqb0VnAm2HE,1263
147
147
  plain/views/exceptions.py,sha256=b4euI49ZUKS9O8AGAcFfiDpstzkRAuuj_uYQXzWNHME,138
@@ -149,8 +149,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
149
149
  plain/views/objects.py,sha256=GGbcfg_9fPZ-PiaBwIHG2e__8GfWDR7JQtQ15wTyiHg,5970
150
150
  plain/views/redirect.py,sha256=daq2cQIkdDF78bt43sjuZxRAyJm_t_SKw6tyPmiXPIc,1985
151
151
  plain/views/templates.py,sha256=ivkI7LU7BXDQ0d4Geab96Is4-Cp03KbIntXRT1J8e6I,2139
152
- plain-0.53.0.dist-info/METADATA,sha256=n3gNCFOwyioHwL_hKBiAmIGxSQG6CBM-XjM-x5EMq5I,4297
153
- plain-0.53.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
154
- plain-0.53.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
155
- plain-0.53.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
156
- plain-0.53.0.dist-info/RECORD,,
152
+ plain-0.54.0.dist-info/METADATA,sha256=uCFa4LMC3-o4Vbr_ZKT-Kb3DGxAy9xW0UWzrEIk3CBU,4488
153
+ plain-0.54.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
154
+ plain-0.54.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
155
+ plain-0.54.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
156
+ plain-0.54.0.dist-info/RECORD,,
File without changes