plone.observability 1.0.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.
Files changed (41) hide show
  1. plone/observability/__init__.py +0 -0
  2. plone/observability/auth.py +28 -0
  3. plone/observability/configure.zcml +30 -0
  4. plone/observability/health/__init__.py +0 -0
  5. plone/observability/health/checks.py +31 -0
  6. plone/observability/health/configure.zcml +9 -0
  7. plone/observability/health/server.py +146 -0
  8. plone/observability/health/wsgi.py +30 -0
  9. plone/observability/interfaces.py +63 -0
  10. plone/observability/metric.py +14 -0
  11. plone/observability/metrics/__init__.py +0 -0
  12. plone/observability/metrics/access.py +54 -0
  13. plone/observability/metrics/configure.zcml +70 -0
  14. plone/observability/metrics/formatters.py +48 -0
  15. plone/observability/metrics/providers/__init__.py +0 -0
  16. plone/observability/metrics/providers/conflict.py +55 -0
  17. plone/observability/metrics/providers/content.py +99 -0
  18. plone/observability/metrics/providers/request.py +202 -0
  19. plone/observability/metrics/providers/system.py +39 -0
  20. plone/observability/metrics/providers/zodb.py +203 -0
  21. plone/observability/metrics/providers/zope_runtime.py +68 -0
  22. plone/observability/metrics/view.py +61 -0
  23. plone/observability/otel/__init__.py +0 -0
  24. plone/observability/otel/catalog.py +91 -0
  25. plone/observability/otel/configure.zcml +66 -0
  26. plone/observability/otel/dbcounts.py +66 -0
  27. plone/observability/otel/exclusions.py +79 -0
  28. plone/observability/otel/instrumentors.py +72 -0
  29. plone/observability/otel/provider.py +47 -0
  30. plone/observability/otel/pubevents.py +91 -0
  31. plone/observability/otel/rendering.py +160 -0
  32. plone/observability/otel/subrequest.py +91 -0
  33. plone/observability/otel/transformchain.py +91 -0
  34. plone/observability/otel/wsgi.py +35 -0
  35. plone/observability/otel/zodb.py +102 -0
  36. plone/observability/spans.py +31 -0
  37. plone_observability-1.0.0.dist-info/METADATA +92 -0
  38. plone_observability-1.0.0.dist-info/RECORD +41 -0
  39. plone_observability-1.0.0.dist-info/WHEEL +4 -0
  40. plone_observability-1.0.0.dist-info/entry_points.txt +7 -0
  41. plone_observability-1.0.0.dist-info/licenses/LICENSE +338 -0
File without changes
@@ -0,0 +1,28 @@
1
+ """Authentication detection for request metrics and tracing (no OTel import)."""
2
+
3
+ from AccessControl.SecurityManagement import getSecurityManager
4
+
5
+
6
+ ENVIRON_KEY = "plone.observability.authenticated"
7
+
8
+
9
+ def get_auth_info():
10
+ """Return (authenticated: bool, user_id: str | None) for the current context.
11
+
12
+ Uses CMFCore's anonymous semantics: a request is anonymous when there is no
13
+ user or the user's name is the Zope/PAS "Anonymous User".
14
+ """
15
+ user = getSecurityManager().getUser()
16
+ if user is None or user.getUserName() == "Anonymous User":
17
+ return False, None
18
+ return True, user.getId()
19
+
20
+
21
+ def capture_auth(event):
22
+ """Pubevent subscriber: stash the auth boolean on the request environ.
23
+
24
+ Runs while the security context is live (IPubSuccess/IPubFailure) so the
25
+ WSGI middleware can read it after publication teardown.
26
+ """
27
+ authenticated, _ = get_auth_info()
28
+ event.request.environ[ENVIRON_KEY] = authenticated
@@ -0,0 +1,30 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ xmlns:zcml="http://namespaces.zope.org/zcml"
4
+ i18n_domain="plone.observability"
5
+ >
6
+
7
+ <include package=".health" />
8
+ <include package=".metrics" />
9
+
10
+ <include
11
+ package=".otel"
12
+ zcml:condition="installed opentelemetry.sdk"
13
+ />
14
+
15
+ <subscriber
16
+ for="ZPublisher.interfaces.IPubSuccess"
17
+ handler=".auth.capture_auth"
18
+ />
19
+
20
+ <subscriber
21
+ for="ZPublisher.interfaces.IPubFailure"
22
+ handler=".auth.capture_auth"
23
+ />
24
+
25
+ <subscriber
26
+ for="ZPublisher.interfaces.IPubBeforeAbort"
27
+ handler=".metrics.providers.conflict.on_pub_before_abort"
28
+ />
29
+
30
+ </configure>
File without changes
@@ -0,0 +1,31 @@
1
+ from plone.observability.interfaces import IReadinessCheck
2
+ from zope.interface import implementer
3
+
4
+ import logging
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ @implementer(IReadinessCheck)
11
+ class ZODBReadinessCheck:
12
+ """Verifies that the ZODB is accessible by opening a connection and reading the root."""
13
+
14
+ name = "zodb"
15
+
16
+ def __init__(self):
17
+ self.db = None # Set during startup
18
+
19
+ def __call__(self):
20
+ if self.db is None:
21
+ return False, "No database reference available"
22
+ try:
23
+ conn = self.db.open()
24
+ try:
25
+ conn.root()
26
+ return True, "ZODB connection ok"
27
+ finally:
28
+ conn.close()
29
+ except Exception as e:
30
+ logger.warning("ZODB readiness check failed: %s", e)
31
+ return False, f"ZODB connection failed: {e}"
@@ -0,0 +1,9 @@
1
+ <configure xmlns="http://namespaces.zope.org/zope">
2
+
3
+ <utility
4
+ factory=".checks.ZODBReadinessCheck"
5
+ provides="plone.observability.interfaces.IReadinessCheck"
6
+ name="zodb"
7
+ />
8
+
9
+ </configure>
@@ -0,0 +1,146 @@
1
+ from http.server import BaseHTTPRequestHandler
2
+ from http.server import HTTPServer
3
+ from plone.observability.interfaces import ILivenessCheck
4
+ from plone.observability.interfaces import IReadinessCheck
5
+ from socketserver import ThreadingMixIn
6
+ from zope.component import getUtilitiesFor
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ import threading
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
19
+ """HTTPServer that handles each request in a new thread."""
20
+
21
+ daemon_threads = True
22
+
23
+ def handle_error(self, request, client_address):
24
+ exc = sys.exc_info()[1]
25
+ if isinstance(exc, (BrokenPipeError, ConnectionResetError)):
26
+ # Probe client disconnected before we finished writing the
27
+ # response (common during warmup when readiness returns 503).
28
+ # Harmless, so do not dump a traceback.
29
+ logger.debug("Health probe connection dropped from %s", client_address)
30
+ return
31
+ super().handle_error(request, client_address)
32
+
33
+
34
+ class HealthRequestHandler(BaseHTTPRequestHandler):
35
+ """HTTP handler for health probe endpoints."""
36
+
37
+ def do_GET(self):
38
+ if self.path == "/live":
39
+ self._handle_live()
40
+ elif self.path == "/ready":
41
+ self._handle_ready()
42
+ elif self.path == "/startup":
43
+ self._handle_startup()
44
+ else:
45
+ self.send_error(404)
46
+
47
+ def _run_checks(self, interface):
48
+ checks = {}
49
+ ok = True
50
+ for name, check in getUtilitiesFor(interface):
51
+ check_ok, message = check()
52
+ checks[name] = {"ok": check_ok, "message": message}
53
+ if not check_ok:
54
+ ok = False
55
+ return ok, checks
56
+
57
+ def _handle_live(self):
58
+ ok, checks = self._run_checks(ILivenessCheck)
59
+ self._send_json(
60
+ 200 if ok else 503, {"status": "ok" if ok else "failed", "checks": checks}
61
+ )
62
+
63
+ def _handle_ready(self):
64
+ ok, checks = self._run_checks(IReadinessCheck)
65
+ if ok:
66
+ self.server.health_server._started = True
67
+ self._send_json(
68
+ 200 if ok else 503, {"status": "ok" if ok else "failed", "checks": checks}
69
+ )
70
+
71
+ def _handle_startup(self):
72
+ # Determine readiness ourselves and latch on first success. Kubernetes
73
+ # does not run the readiness probe until the startup probe has already
74
+ # succeeded, so /startup cannot wait for /ready to have been polled --
75
+ # it must evaluate the readiness checks itself. Once started it stays
76
+ # green (startup semantics: "did the process finish booting").
77
+ hs = self.server.health_server
78
+ if not hs._started:
79
+ ok, _ = self._run_checks(IReadinessCheck)
80
+ if ok:
81
+ hs._started = True
82
+ started = hs._started
83
+ self._send_json(
84
+ 200 if started else 503,
85
+ {"status": "ok" if started else "starting"},
86
+ )
87
+
88
+ def _send_json(self, status, data):
89
+ body = json.dumps(data).encode("utf-8")
90
+ try:
91
+ self.send_response(status)
92
+ self.send_header("Content-Type", "application/json")
93
+ self.send_header("Content-Length", str(len(body)))
94
+ self.end_headers()
95
+ self.wfile.write(body)
96
+ except (BrokenPipeError, ConnectionResetError):
97
+ # Probe client disconnected before we finished writing (common
98
+ # during warmup when readiness returns 503). Harmless.
99
+ logger.debug("Health probe client disconnected early")
100
+
101
+ def log_message(self, format, *args): # noqa: A002 (stdlib override signature)
102
+ # Suppress default stderr logging
103
+ logger.debug(format, *args)
104
+
105
+
106
+ class HealthServer:
107
+ """Manages the health check HTTP server on a separate daemon thread."""
108
+
109
+ def __init__(self, host=None, port=None):
110
+ self.host = host or os.environ.get("PLONE_OBSERVABILITY_HEALTH_HOST", "0.0.0.0")
111
+ self.port = port or int(
112
+ os.environ.get("PLONE_OBSERVABILITY_HEALTH_PORT", "8081")
113
+ )
114
+ self._started = False
115
+ self._thread = None
116
+ self._httpd = None
117
+ self.db = None # Set during startup to hold ZODB Database reference
118
+
119
+ def start(self):
120
+ if self.port == 0:
121
+ logger.info("Health server disabled (port=0)")
122
+ return
123
+ try:
124
+ self._httpd = ThreadingHTTPServer(
125
+ (self.host, self.port), HealthRequestHandler
126
+ )
127
+ except OSError as exc:
128
+ logger.error(
129
+ "Health server could not bind %s:%s: %s", self.host, self.port, exc
130
+ )
131
+ self._httpd = None
132
+ return
133
+ self._httpd.health_server = self
134
+ self._thread = threading.Thread(
135
+ target=self._httpd.serve_forever,
136
+ name="plone.observability.health",
137
+ daemon=True,
138
+ )
139
+ self._thread.start()
140
+ logger.info("Health server started on %s:%s", self.host, self.port)
141
+
142
+ def stop(self):
143
+ if self._httpd is not None:
144
+ self._httpd.shutdown()
145
+ self._httpd = None
146
+ self._thread = None
@@ -0,0 +1,30 @@
1
+ """Serving-only startup of the health server via a PasteDeploy filter.
2
+
3
+ The WSGI pipeline (zope.ini) is only built when the server actually serves;
4
+ zconsole/scripts load zope.conf via make_wsgi_app and never build the pipeline.
5
+ So this filter is a serving-only hook and never runs under zconsole.
6
+ """
7
+
8
+ from plone.observability.health.server import HealthServer
9
+ from plone.observability.interfaces import IReadinessCheck
10
+ from zope.component import queryUtility
11
+
12
+ import logging
13
+ import Zope2
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _health_server = HealthServer()
19
+
20
+
21
+ def make_filter(app, global_conf, **local_conf):
22
+ """PasteDeploy filter factory: start the health server (serving only)."""
23
+ db = Zope2.DB
24
+ _health_server.db = db
25
+ zodb_check = queryUtility(IReadinessCheck, name="zodb")
26
+ if zodb_check is not None:
27
+ zodb_check.db = db
28
+ logger.info("Starting plone.observability health server")
29
+ _health_server.start()
30
+ return app
@@ -0,0 +1,63 @@
1
+ from zope.interface import Attribute
2
+ from zope.interface import Interface
3
+
4
+
5
+ class ILivenessCheck(Interface):
6
+ """Named utility for liveness checks.
7
+
8
+ Implementations MUST NOT access ZODB or any blocking resource.
9
+ These run on the health server thread, outside of Zope context.
10
+ """
11
+
12
+ name = Attribute("Human-readable name of the check")
13
+
14
+ def __call__():
15
+ """Run the liveness check.
16
+
17
+ Returns a tuple of (ok: bool, message: str).
18
+ """
19
+
20
+
21
+ class IReadinessCheck(Interface):
22
+ """Named utility for readiness checks.
23
+
24
+ May access ZODB and other resources. These checks verify the
25
+ application can serve real requests.
26
+ """
27
+
28
+ name = Attribute("Human-readable name of the check")
29
+
30
+ def __call__():
31
+ """Run the readiness check.
32
+
33
+ Returns a tuple of (ok: bool, message: str).
34
+ """
35
+
36
+
37
+ class IMetricProvider(Interface):
38
+ """Adapter on the application root providing metrics.
39
+
40
+ The scope attribute indicates whether metrics are global (same across
41
+ all instances sharing a ZODB) or instance-specific.
42
+ """
43
+
44
+ name = Attribute("Provider name")
45
+ scope = Attribute("'global' (shared across instances) or 'instance'")
46
+
47
+ def collect():
48
+ """Collect current metrics.
49
+
50
+ Yields Metric instances.
51
+ """
52
+
53
+
54
+ class IMetricFormatter(Interface):
55
+ """Named utility that serializes metrics to a specific wire format."""
56
+
57
+ content_type = Attribute("MIME type for the response")
58
+
59
+ def format(metrics):
60
+ """Format a sequence of Metric objects.
61
+
62
+ Returns a string in the target format.
63
+ """
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+ from dataclasses import field
3
+
4
+
5
+ @dataclass
6
+ class Metric:
7
+ """A single metric data point."""
8
+
9
+ name: str
10
+ value: float
11
+ type: str # "gauge", "counter", "histogram", "info"
12
+ scope: str # "global" or "instance"
13
+ help: str
14
+ labels: dict = field(default_factory=dict)
File without changes
@@ -0,0 +1,54 @@
1
+ from ipaddress import ip_address
2
+ from ipaddress import ip_network
3
+
4
+ import os
5
+
6
+
7
+ def _get_cidrs(env_var, default=""):
8
+ raw = os.environ.get(env_var, default).strip()
9
+ if not raw:
10
+ return []
11
+ return [ip_network(c.strip(), strict=False) for c in raw.split(",") if c.strip()]
12
+
13
+
14
+ def _get_client_ip(peer_ip, headers):
15
+ """Determine the real client IP, respecting trusted proxies."""
16
+ trusted = _get_cidrs("PLONE_OBSERVABILITY_TRUSTED_PROXIES", default="127.0.0.1,::1")
17
+ try:
18
+ peer = ip_address(peer_ip)
19
+ except ValueError:
20
+ return peer_ip
21
+
22
+ if not any(peer in net for net in trusted):
23
+ return peer_ip
24
+
25
+ forwarded = headers.get("X-Forwarded-For", "")
26
+ if not forwarded:
27
+ return peer_ip
28
+
29
+ # Walk the chain from right to left, skipping trusted proxies.
30
+ # The first non-trusted IP is the real client.
31
+ parts = [p.strip() for p in forwarded.split(",")]
32
+ for part in reversed(parts):
33
+ try:
34
+ addr = ip_address(part)
35
+ except ValueError:
36
+ continue
37
+ if not any(addr in net for net in trusted):
38
+ return part
39
+ return peer_ip
40
+
41
+
42
+ def is_allowed(peer_ip, headers):
43
+ """Check if a request is allowed based on IP allow-list."""
44
+ allowlist = _get_cidrs("PLONE_OBSERVABILITY_METRICS_ALLOWLIST")
45
+ if not allowlist:
46
+ return True
47
+
48
+ client_ip_str = _get_client_ip(peer_ip, headers)
49
+ try:
50
+ client_ip = ip_address(client_ip_str)
51
+ except ValueError:
52
+ return False
53
+
54
+ return any(client_ip in net for net in allowlist)
@@ -0,0 +1,70 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ xmlns:browser="http://namespaces.zope.org/browser"
4
+ >
5
+
6
+ <!-- Formatters -->
7
+ <utility
8
+ factory=".formatters.PrometheusFormatter"
9
+ provides="plone.observability.interfaces.IMetricFormatter"
10
+ name="prometheus"
11
+ />
12
+
13
+ <utility
14
+ factory=".formatters.JSONFormatter"
15
+ provides="plone.observability.interfaces.IMetricFormatter"
16
+ name="json"
17
+ />
18
+
19
+ <!-- Metrics view -->
20
+ <browser:page
21
+ name="metrics"
22
+ for="OFS.interfaces.IApplication"
23
+ class=".view.MetricsView"
24
+ permission="zope.Public"
25
+ />
26
+
27
+ <!-- Built-in metric providers -->
28
+ <adapter
29
+ factory=".providers.zope_runtime.ZopeRuntimeMetricProvider"
30
+ provides="plone.observability.interfaces.IMetricProvider"
31
+ for="OFS.interfaces.IApplication"
32
+ name="zope_runtime"
33
+ />
34
+
35
+ <adapter
36
+ factory=".providers.zodb.ZODBMetricProvider"
37
+ provides="plone.observability.interfaces.IMetricProvider"
38
+ for="OFS.interfaces.IApplication"
39
+ name="zodb"
40
+ />
41
+
42
+ <adapter
43
+ factory=".providers.content.ContentMetricProvider"
44
+ provides="plone.observability.interfaces.IMetricProvider"
45
+ for="OFS.interfaces.IApplication"
46
+ name="content"
47
+ />
48
+
49
+ <adapter
50
+ factory=".providers.request.RequestMetricProvider"
51
+ provides="plone.observability.interfaces.IMetricProvider"
52
+ for="OFS.interfaces.IApplication"
53
+ name="request"
54
+ />
55
+
56
+ <adapter
57
+ factory=".providers.system.SystemMetricProvider"
58
+ provides="plone.observability.interfaces.IMetricProvider"
59
+ for="OFS.interfaces.IApplication"
60
+ name="system"
61
+ />
62
+
63
+ <adapter
64
+ factory=".providers.conflict.ConflictMetricProvider"
65
+ provides="plone.observability.interfaces.IMetricProvider"
66
+ for="OFS.interfaces.IApplication"
67
+ name="conflict"
68
+ />
69
+
70
+ </configure>
@@ -0,0 +1,48 @@
1
+ from plone.observability.interfaces import IMetricFormatter
2
+ from zope.interface import implementer
3
+
4
+ import json
5
+
6
+
7
+ @implementer(IMetricFormatter)
8
+ class PrometheusFormatter:
9
+ """Formats metrics in Prometheus text exposition format."""
10
+
11
+ content_type = "text/plain; version=0.0.4; charset=utf-8"
12
+
13
+ def format(self, metrics):
14
+ lines = []
15
+ seen = set()
16
+ for m in metrics:
17
+ if m.name not in seen:
18
+ lines.append(f"# HELP {m.name} {m.help}")
19
+ lines.append(f"# TYPE {m.name} {m.type}")
20
+ seen.add(m.name)
21
+ labels = {"scope": m.scope}
22
+ labels.update(m.labels)
23
+ label_str = ",".join(f'{k}="{v}"' for k, v in labels.items())
24
+ value = int(m.value) if m.value == int(m.value) else m.value
25
+ lines.append(f"{m.name}{{{label_str}}} {value}")
26
+ return "\n".join(lines) + "\n"
27
+
28
+
29
+ @implementer(IMetricFormatter)
30
+ class JSONFormatter:
31
+ """Formats metrics as JSON."""
32
+
33
+ content_type = "application/json"
34
+
35
+ def format(self, metrics):
36
+ data = []
37
+ for m in metrics:
38
+ entry = {
39
+ "name": m.name,
40
+ "value": m.value,
41
+ "type": m.type,
42
+ "scope": m.scope,
43
+ "help": m.help,
44
+ }
45
+ if m.labels:
46
+ entry["labels"] = m.labels
47
+ data.append(entry)
48
+ return json.dumps(data)
File without changes
@@ -0,0 +1,55 @@
1
+ from collections import defaultdict
2
+ from plone.observability.interfaces import IMetricProvider
3
+ from plone.observability.metric import Metric
4
+ from ZODB.POSException import ConflictError
5
+ from zope.interface import implementer
6
+
7
+ import threading
8
+
9
+
10
+ class ConflictTracker:
11
+ """Thread-safe ZODB conflict counter, partitioned by retry outcome."""
12
+
13
+ def __init__(self):
14
+ self._lock = threading.Lock()
15
+ self.counts = defaultdict(int) # {"true"|"false": int}
16
+
17
+ def record(self, retry):
18
+ key = "true" if retry else "false"
19
+ with self._lock:
20
+ self.counts[key] += 1
21
+
22
+
23
+ tracker = ConflictTracker()
24
+
25
+
26
+ def on_pub_before_abort(event):
27
+ """IPubBeforeAbort subscriber: count ConflictErrors, labelled by retry."""
28
+ exc = event.exc_info[1] if event.exc_info else None
29
+ if isinstance(exc, ConflictError):
30
+ tracker.record(bool(event.retry))
31
+
32
+
33
+ @implementer(IMetricProvider)
34
+ class ConflictMetricProvider:
35
+ """Provides ZODB conflict metrics from the pubevent tracker."""
36
+
37
+ name = "conflict"
38
+ scope = "instance"
39
+
40
+ def __init__(self, context):
41
+ self.context = context
42
+
43
+ def collect(self):
44
+ for retry in ("true", "false"):
45
+ yield Metric(
46
+ name="plone_zodb_conflicts_total",
47
+ value=tracker.counts[retry],
48
+ type="counter",
49
+ scope="instance",
50
+ help=(
51
+ "ZODB conflict errors during publish "
52
+ "(retry=true was retried, retry=false gave up)"
53
+ ),
54
+ labels={"retry": retry},
55
+ )