prodkit 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {prodkit-0.2.1 → prodkit-0.3.0}/CHANGELOG.md +25 -0
  2. {prodkit-0.2.1 → prodkit-0.3.0}/PKG-INFO +46 -12
  3. {prodkit-0.2.1 → prodkit-0.3.0}/README.md +20 -9
  4. {prodkit-0.2.1 → prodkit-0.3.0}/pyproject.toml +18 -1
  5. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/__init__.py +7 -1
  6. prodkit-0.3.0/src/prodkit/contracts/__init__.py +5 -0
  7. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/contracts/plugin.py +8 -2
  8. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/config.py +34 -1
  9. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/production.py +3 -0
  10. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/__init__.py +12 -0
  11. prodkit-0.3.0/src/prodkit/plugins/_redis.py +34 -0
  12. prodkit-0.3.0/src/prodkit/plugins/cache/__init__.py +150 -0
  13. prodkit-0.3.0/src/prodkit/plugins/metrics/__init__.py +173 -0
  14. prodkit-0.3.0/src/prodkit/plugins/rate_limit/__init__.py +169 -0
  15. prodkit-0.3.0/src/prodkit/plugins/rate_limit/backends.py +117 -0
  16. prodkit-0.3.0/src/prodkit/plugins/tracing/__init__.py +231 -0
  17. prodkit-0.3.0/tests/unit/test_cache.py +151 -0
  18. {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_config.py +44 -0
  19. {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_doctor.py +5 -2
  20. prodkit-0.3.0/tests/unit/test_metrics.py +184 -0
  21. prodkit-0.3.0/tests/unit/test_rate_limit.py +216 -0
  22. prodkit-0.3.0/tests/unit/test_tracing.py +179 -0
  23. prodkit-0.2.1/src/prodkit/plugins/rate_limit/__init__.py +0 -147
  24. prodkit-0.2.1/tests/unit/__init__.py +0 -0
  25. prodkit-0.2.1/tests/unit/test_rate_limit.py +0 -97
  26. {prodkit-0.2.1 → prodkit-0.3.0}/.gitignore +0 -0
  27. {prodkit-0.2.1 → prodkit-0.3.0}/LICENSE +0 -0
  28. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/__init__.py +0 -0
  29. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/app.py +0 -0
  30. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/loader.py +0 -0
  31. {prodkit-0.2.1/src/prodkit/contracts → prodkit-0.3.0/src/prodkit/core}/__init__.py +0 -0
  32. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/context.py +0 -0
  33. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/doctor.py +0 -0
  34. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/event_bus.py +0 -0
  35. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/exceptions.py +0 -0
  36. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/lifecycle.py +0 -0
  37. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/plugin_manager.py +0 -0
  38. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/registry.py +0 -0
  39. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/compression/__init__.py +0 -0
  40. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/cors/__init__.py +0 -0
  41. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/errors/__init__.py +0 -0
  42. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/health/__init__.py +0 -0
  43. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/logging/__init__.py +0 -0
  44. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/request_id/__init__.py +0 -0
  45. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/security/__init__.py +0 -0
  46. {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/py.typed +0 -0
  47. {prodkit-0.2.1/src/prodkit/core → prodkit-0.3.0/tests}/__init__.py +0 -0
  48. {prodkit-0.2.1 → prodkit-0.3.0}/tests/conftest.py +0 -0
  49. {prodkit-0.2.1/tests → prodkit-0.3.0/tests/integration}/__init__.py +0 -0
  50. {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_cli.py +0 -0
  51. {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_logging.py +0 -0
  52. {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_production.py +0 -0
  53. {prodkit-0.2.1/tests/integration → prodkit-0.3.0/tests/unit}/__init__.py +0 -0
  54. {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_kernel.py +0 -0
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-08-14
11
+
12
+ ### Added
13
+ - **Prometheus metrics plugin** (`pip install prodkit[metrics]`): request
14
+ count, latency histogram, in-flight gauge. Exposed at `/metrics` (or a
15
+ custom path). Metrics use route templates as labels (bounded cardinality).
16
+ Dedicated `CollectorRegistry` per instance — no global-state collisions.
17
+ - **Cache plugin** (`cache={...}`): a named cache service published in the
18
+ registry under `"cache"`. Memory (per-process LRU with TTL) and Redis
19
+ (shared across workers) backends. Values must be JSON-serializable.
20
+ - **OpenTelemetry tracing plugin** (`pip install prodkit[otel]`): automatic
21
+ request spans with W3C `traceparent` propagation, configurable sampler
22
+ (`sample_rate`), and OTLP/console/none exporters. The `TracerProvider` is
23
+ published in the registry as `"tracer"`.
24
+ - **Redis backend for rate limiting** (`rate_limit.backend="redis"`): shared
25
+ aligned fixed-window counters across all workers and hosts. Fails open on
26
+ Redis errors (availability > strict limiting). Readiness check via `/ready`.
27
+
28
+ ### Changed
29
+ - CI installs `otel` and `metrics` extras for full test coverage.
30
+ - `TracingMiddleware` now uses `context.attach()`/`detach()` for correct W3C
31
+ trace context propagation (previously passed unsupported `context` kwarg).
32
+ - Dev dependencies now include `anyio` and `pytest-anyio` for async tests.
33
+
34
+
10
35
  ## [0.2.1] - 2026-07-23
11
36
 
12
37
  ### Changed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: prodkit
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: The production framework for FastAPI. One line. Production ready.
5
5
  Project-URL: Homepage, https://github.com/Pushkarpant/PRODKIT
6
6
  Project-URL: Documentation, https://github.com/Pushkarpant/PRODKIT#readme
@@ -10,7 +10,7 @@ Project-URL: Issues, https://github.com/Pushkarpant/PRODKIT/issues
10
10
  Author-email: Pushkar Pant <pantpushkar4@gmail.com>
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
13
- Keywords: fastapi,health-check,logging,middleware,observability,production,security
13
+ Keywords: fastapi,health-check,logging,metrics,middleware,observability,opentelemetry,production,security,tracing
14
14
  Classifier: Development Status :: 3 - Alpha
15
15
  Classifier: Framework :: FastAPI
16
16
  Classifier: Intended Audience :: Developers
@@ -28,21 +28,44 @@ Requires-Dist: fastapi>=0.110
28
28
  Requires-Dist: pydantic-settings>=2.1
29
29
  Requires-Dist: pydantic>=2.5
30
30
  Requires-Dist: tomli>=2.0; python_version < '3.11'
31
+ Provides-Extra: all
32
+ Requires-Dist: brotli-asgi>=1.4; extra == 'all'
33
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'all'
34
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20; extra == 'all'
35
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'all'
36
+ Requires-Dist: prometheus-client>=0.20; extra == 'all'
37
+ Requires-Dist: redis>=5.0; extra == 'all'
38
+ Requires-Dist: rich>=13; extra == 'all'
39
+ Requires-Dist: typer>=0.12; extra == 'all'
31
40
  Provides-Extra: brotli
32
41
  Requires-Dist: brotli-asgi>=1.4; extra == 'brotli'
33
42
  Provides-Extra: cli
34
43
  Requires-Dist: rich>=13; extra == 'cli'
35
44
  Requires-Dist: typer>=0.12; extra == 'cli'
36
45
  Provides-Extra: dev
46
+ Requires-Dist: anyio[trio]>=4.0; extra == 'dev'
47
+ Requires-Dist: fakeredis[lua]>=2.21; extra == 'dev'
37
48
  Requires-Dist: httpx>=0.27; extra == 'dev'
38
49
  Requires-Dist: import-linter>=2.0; extra == 'dev'
39
50
  Requires-Dist: mypy>=1.11; extra == 'dev'
51
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'dev'
52
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'dev'
53
+ Requires-Dist: prometheus-client>=0.20; extra == 'dev'
54
+ Requires-Dist: pytest-anyio>=0.0.0; extra == 'dev'
40
55
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
41
56
  Requires-Dist: pytest>=8.0; extra == 'dev'
42
57
  Requires-Dist: rich>=13; extra == 'dev'
43
58
  Requires-Dist: ruff>=0.6; extra == 'dev'
44
59
  Requires-Dist: tomli>=2.0; extra == 'dev'
45
60
  Requires-Dist: typer>=0.12; extra == 'dev'
61
+ Provides-Extra: metrics
62
+ Requires-Dist: prometheus-client>=0.20; extra == 'metrics'
63
+ Provides-Extra: otel
64
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
65
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20; extra == 'otel'
66
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
67
+ Provides-Extra: redis
68
+ Requires-Dist: redis>=5.0; extra == 'redis'
46
69
  Description-Content-Type: text/markdown
47
70
 
48
71
  # ProdKit
@@ -99,8 +122,16 @@ pip install "prodkit[cli]" # + the `prodkit doctor` CLI (typer + rich)
99
122
  ```
100
123
 
101
124
  Requires Python 3.10+ and FastAPI 0.110+. The base install depends only on
102
- FastAPI and Pydantic — nothing else. Optional extras: `cli` (CLI),
103
- `brotli` (Brotli compression).
125
+ FastAPI and Pydantic — nothing else. Optional extras:
126
+
127
+ | Extra | Adds |
128
+ |---|---|
129
+ | `cli` | `prodkit doctor` CLI (typer + rich) |
130
+ | `metrics` | Prometheus `/metrics` endpoint |
131
+ | `otel` | OpenTelemetry tracing (OTLP export) |
132
+ | `redis` | Shared Redis backends (rate-limit, cache) |
133
+ | `brotli` | Brotli compression |
134
+ | `all` | Everything above |
104
135
 
105
136
  ## Quick Start
106
137
 
@@ -151,7 +182,10 @@ Production(app, environment="development")
151
182
  | ❤️ **Health endpoints** | `/health`, `/live` (liveness) and `/ready` (readiness — aggregates checks from every plugin, 503 until all pass). Kubernetes-native. |
152
183
  | 🌐 **CORS** | Explicit origins only; the wildcard-with-credentials footgun is refused at boot. |
153
184
  | 📦 **Compression** | Gzip for responses over 500 bytes. |
154
- | 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. In-memory backend (Redis in v0.3). |
185
+ | 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. Memory or Redis backend (shared across workers). |
186
+ | 📊 **Prometheus metrics** | Request count, latency histogram, in-flight gauge at `/metrics`. Route-template labels (bounded cardinality). `pip install prodkit[metrics]` |
187
+ | 🗄️ **Cache service** | Memory (LRU + TTL) or Redis backend, published in the registry. `await cache.get(key)` / `.set(key, value, ttl=60)`. |
188
+ | 🔭 **OpenTelemetry tracing** | Automatic request spans with W3C `traceparent` propagation. OTLP, console, or none exporter. `pip install prodkit[otel]` |
155
189
  | 🩺 **`prodkit doctor`** | CLI production-readiness audit with a 0–100 score. `--strict` gates CI. |
156
190
  | 🔌 **Plugin system** | Every feature above is a plugin. Write your own with optional hooks incl. `doctor()`. |
157
191
 
@@ -301,13 +335,13 @@ plugins score too.
301
335
 
302
336
  ## Project Status
303
337
 
304
- **v0.2.1 — alpha.** Core kernel, eight built-in plugins (incl. rate-limiting),
305
- the `prodkit doctor` CLI with a production-readiness score, strict mypy, CI
306
- across Python 3.10–3.13.
338
+ **v0.3.0 — alpha.** Core kernel, eleven built-in plugins (metrics, cache, tracing,
339
+ rate-limiting with Redis), the `prodkit doctor` CLI, strict mypy, CI across
340
+ Python 3.10–3.13.
307
341
 
308
- Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), Prometheus metrics +
309
- Redis backends (v0.3), Dockerfile/nginx/CI generators (v0.4), public plugin SDK
310
- (v0.5), auth helpers (v0.6), stable API (v1.0).
342
+ Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), ✅ Prometheus metrics +
343
+ Redis backends + OpenTelemetry tracing (v0.3), Dockerfile/nginx/CI generators
344
+ (v0.4), public plugin SDK (v0.5), auth helpers (v0.6), stable API (v1.0).
311
345
  Full details in [docs/ARCHITECTURE.md](https://github.com/Pushkarpant/PRODKIT/blob/main/docs/ARCHITECTURE.md).
312
346
 
313
347
  ## Contributing
@@ -52,8 +52,16 @@ pip install "prodkit[cli]" # + the `prodkit doctor` CLI (typer + rich)
52
52
  ```
53
53
 
54
54
  Requires Python 3.10+ and FastAPI 0.110+. The base install depends only on
55
- FastAPI and Pydantic — nothing else. Optional extras: `cli` (CLI),
56
- `brotli` (Brotli compression).
55
+ FastAPI and Pydantic — nothing else. Optional extras:
56
+
57
+ | Extra | Adds |
58
+ |---|---|
59
+ | `cli` | `prodkit doctor` CLI (typer + rich) |
60
+ | `metrics` | Prometheus `/metrics` endpoint |
61
+ | `otel` | OpenTelemetry tracing (OTLP export) |
62
+ | `redis` | Shared Redis backends (rate-limit, cache) |
63
+ | `brotli` | Brotli compression |
64
+ | `all` | Everything above |
57
65
 
58
66
  ## Quick Start
59
67
 
@@ -104,7 +112,10 @@ Production(app, environment="development")
104
112
  | ❤️ **Health endpoints** | `/health`, `/live` (liveness) and `/ready` (readiness — aggregates checks from every plugin, 503 until all pass). Kubernetes-native. |
105
113
  | 🌐 **CORS** | Explicit origins only; the wildcard-with-credentials footgun is refused at boot. |
106
114
  | 📦 **Compression** | Gzip for responses over 500 bytes. |
107
- | 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. In-memory backend (Redis in v0.3). |
115
+ | 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. Memory or Redis backend (shared across workers). |
116
+ | 📊 **Prometheus metrics** | Request count, latency histogram, in-flight gauge at `/metrics`. Route-template labels (bounded cardinality). `pip install prodkit[metrics]` |
117
+ | 🗄️ **Cache service** | Memory (LRU + TTL) or Redis backend, published in the registry. `await cache.get(key)` / `.set(key, value, ttl=60)`. |
118
+ | 🔭 **OpenTelemetry tracing** | Automatic request spans with W3C `traceparent` propagation. OTLP, console, or none exporter. `pip install prodkit[otel]` |
108
119
  | 🩺 **`prodkit doctor`** | CLI production-readiness audit with a 0–100 score. `--strict` gates CI. |
109
120
  | 🔌 **Plugin system** | Every feature above is a plugin. Write your own with optional hooks incl. `doctor()`. |
110
121
 
@@ -254,13 +265,13 @@ plugins score too.
254
265
 
255
266
  ## Project Status
256
267
 
257
- **v0.2.1 — alpha.** Core kernel, eight built-in plugins (incl. rate-limiting),
258
- the `prodkit doctor` CLI with a production-readiness score, strict mypy, CI
259
- across Python 3.10–3.13.
268
+ **v0.3.0 — alpha.** Core kernel, eleven built-in plugins (metrics, cache, tracing,
269
+ rate-limiting with Redis), the `prodkit doctor` CLI, strict mypy, CI across
270
+ Python 3.10–3.13.
260
271
 
261
- Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), Prometheus metrics +
262
- Redis backends (v0.3), Dockerfile/nginx/CI generators (v0.4), public plugin SDK
263
- (v0.5), auth helpers (v0.6), stable API (v1.0).
272
+ Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), ✅ Prometheus metrics +
273
+ Redis backends + OpenTelemetry tracing (v0.3), Dockerfile/nginx/CI generators
274
+ (v0.4), public plugin SDK (v0.5), auth helpers (v0.6), stable API (v1.0).
264
275
  Full details in [docs/ARCHITECTURE.md](https://github.com/Pushkarpant/PRODKIT/blob/main/docs/ARCHITECTURE.md).
265
276
 
266
277
  ## Contributing
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "prodkit"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "The production framework for FastAPI. One line. Production ready."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,6 +19,9 @@ keywords = [
19
19
  "observability",
20
20
  "logging",
21
21
  "health-check",
22
+ "metrics",
23
+ "tracing",
24
+ "opentelemetry",
22
25
  ]
23
26
  classifiers = [
24
27
  "Development Status :: 3 - Alpha",
@@ -44,9 +47,19 @@ dependencies = [
44
47
  [project.optional-dependencies]
45
48
  brotli = ["brotli-asgi>=1.4"]
46
49
  cli = ["typer>=0.12", "rich>=13"]
50
+ metrics = ["prometheus-client>=0.20"]
51
+ redis = ["redis>=5.0"]
52
+ otel = [
53
+ "opentelemetry-api>=1.20",
54
+ "opentelemetry-sdk>=1.20",
55
+ "opentelemetry-exporter-otlp-proto-grpc>=1.20",
56
+ ]
57
+ all = ["prodkit[metrics,redis,otel,brotli,cli]"]
47
58
  dev = [
48
59
  "pytest>=8.0",
49
60
  "pytest-cov>=5.0",
61
+ "anyio[trio]>=4.0",
62
+ "pytest-anyio>=0.0.0",
50
63
  "httpx>=0.27",
51
64
  "ruff>=0.6",
52
65
  "mypy>=1.11",
@@ -54,6 +67,10 @@ dev = [
54
67
  "tomli>=2.0", # so mypy (python_version=3.10) can type-check the fallback import
55
68
  "typer>=0.12", # CLI is type-checked and tested in CI
56
69
  "rich>=13",
70
+ "prometheus-client>=0.20",
71
+ "fakeredis[lua]>=2.21",
72
+ "opentelemetry-api>=1.20",
73
+ "opentelemetry-sdk>=1.20",
57
74
  ]
58
75
 
59
76
  [project.scripts]
@@ -9,15 +9,18 @@ Production(app)
9
9
 
10
10
  from prodkit.contracts.plugin import Audit, Check, Plugin
11
11
  from prodkit.core.config import (
12
+ CacheConfig,
12
13
  CompressionConfig,
13
14
  CORSConfig,
14
15
  ErrorsConfig,
15
16
  HealthConfig,
16
17
  LoggingConfig,
18
+ MetricsConfig,
17
19
  ProdKitConfig,
18
20
  RateLimitConfig,
19
21
  RequestIDConfig,
20
22
  SecurityConfig,
23
+ TracingConfig,
21
24
  )
22
25
  from prodkit.core.context import Context
23
26
  from prodkit.core.exceptions import (
@@ -34,17 +37,19 @@ from prodkit.plugins import builtin_plugins
34
37
  # root — the kernel itself never imports from prodkit.plugins.
35
38
  set_builtin_factory(builtin_plugins)
36
39
 
37
- __version__ = "0.2.1"
40
+ __version__ = "0.3.0"
38
41
 
39
42
  __all__ = [
40
43
  "Audit",
41
44
  "CORSConfig",
45
+ "CacheConfig",
42
46
  "Check",
43
47
  "CompressionConfig",
44
48
  "Context",
45
49
  "ErrorsConfig",
46
50
  "HealthConfig",
47
51
  "LoggingConfig",
52
+ "MetricsConfig",
48
53
  "Plugin",
49
54
  "PluginDependencyError",
50
55
  "PluginError",
@@ -56,5 +61,6 @@ __all__ = [
56
61
  "RequestIDConfig",
57
62
  "SecurityConfig",
58
63
  "ServiceNotFoundError",
64
+ "TracingConfig",
59
65
  "__version__",
60
66
  ]
@@ -0,0 +1,5 @@
1
+ """ProdKit plugin contract and supporting types."""
2
+
3
+ from prodkit.contracts.plugin import Audit, AuditStatus, Check, Plugin
4
+
5
+ __all__ = ["Audit", "AuditStatus", "Check", "Plugin"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Awaitable
5
6
  from dataclasses import dataclass
6
7
  from typing import TYPE_CHECKING, ClassVar, Literal
7
8
 
@@ -72,8 +73,12 @@ class Plugin:
72
73
  async def shutdown(self, ctx: Context) -> None:
73
74
  """Release resources gracefully."""
74
75
 
75
- def checks(self, ctx: Context) -> list[Check]:
76
- """Readiness checks, aggregated by the health plugin's /ready."""
76
+ def checks(self, ctx: Context) -> list[Check] | Awaitable[list[Check]]:
77
+ """Readiness checks, aggregated by the health plugin's /ready.
78
+
79
+ May be overridden as ``async def`` (e.g. to ping a backend); the
80
+ health plugin awaits awaitable results.
81
+ """
77
82
  return []
78
83
 
79
84
  def doctor(self, ctx: Context) -> list[Audit]:
@@ -89,6 +94,7 @@ class Plugin:
89
94
  PRIORITY_REQUEST_ID = 100
90
95
  PRIORITY_LOGGING = 200
91
96
  PRIORITY_ERRORS = 250
97
+ PRIORITY_TRACING = 290
92
98
  PRIORITY_METRICS = 300
93
99
  PRIORITY_SECURITY = 400
94
100
  PRIORITY_CORS = 500
@@ -93,7 +93,37 @@ class RateLimitConfig(_Section):
93
93
  enabled: bool = False # opt-in: an unexpected 429 is worse than no limit
94
94
  # "<count>/<second|minute|hour>", parsed and validated by the plugin.
95
95
  default: str = "100/minute"
96
- by: Literal["ip"] = "ip" # v0.2 keys on client IP; per-user/route land later
96
+ by: Literal["ip"] = "ip" # keys on client IP; per-user/route land later
97
+ # "memory" is per-process; "redis" shares the limit across workers/hosts.
98
+ backend: Literal["memory", "redis"] = "memory"
99
+ redis_url: str = "redis://localhost:6379/0"
100
+ key_prefix: str = "prodkit:ratelimit:"
101
+
102
+
103
+ class MetricsConfig(_Section):
104
+ enabled: bool = False # opt-in: needs prodkit[metrics]
105
+ path: str = "/metrics"
106
+ buckets: list[float] | None = None # None -> prometheus-client defaults
107
+ # Paths never measured (the metrics path itself is always excluded).
108
+ exclude_paths: list[str] = Field(default_factory=list)
109
+
110
+
111
+ class CacheConfig(_Section):
112
+ enabled: bool = False # opt-in: a service other code asks for, not middleware
113
+ backend: Literal["memory", "redis"] = "memory"
114
+ redis_url: str = "redis://localhost:6379/0"
115
+ default_ttl: int = 300 # seconds; 0 = no expiry
116
+ key_prefix: str = "prodkit:cache:"
117
+ max_entries: int = 1024 # memory backend LRU bound
118
+
119
+
120
+ class TracingConfig(_Section):
121
+ enabled: bool = False # opt-in: needs prodkit[otel]
122
+ service_name: str | None = None # None -> the FastAPI app's title
123
+ exporter: Literal["otlp", "console", "none"] = "otlp"
124
+ # None defers to the standard OTEL_EXPORTER_OTLP_* environment variables.
125
+ endpoint: str | None = None
126
+ sample_rate: float = Field(default=1.0, ge=0.0, le=1.0)
97
127
 
98
128
 
99
129
  class ProdKitConfig(_Section):
@@ -109,6 +139,9 @@ class ProdKitConfig(_Section):
109
139
  cors: CORSConfig = Field(default_factory=CORSConfig)
110
140
  compression: CompressionConfig = Field(default_factory=CompressionConfig)
111
141
  rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
142
+ metrics: MetricsConfig = Field(default_factory=MetricsConfig)
143
+ cache: CacheConfig = Field(default_factory=CacheConfig)
144
+ tracing: TracingConfig = Field(default_factory=TracingConfig)
112
145
 
113
146
 
114
147
  # Profile defaults: applied beneath toml/env/args. The one-liner must be
@@ -45,6 +45,9 @@ _TOGGLEABLE = (
45
45
  "cors",
46
46
  "compression",
47
47
  "rate_limit",
48
+ "metrics",
49
+ "cache",
50
+ "tracing",
48
51
  )
49
52
 
50
53
 
@@ -10,14 +10,17 @@ from __future__ import annotations
10
10
 
11
11
  from typing import TYPE_CHECKING
12
12
 
13
+ from prodkit.plugins.cache import CachePlugin
13
14
  from prodkit.plugins.compression import CompressionPlugin
14
15
  from prodkit.plugins.cors import CORSPlugin
15
16
  from prodkit.plugins.errors import ErrorsPlugin
16
17
  from prodkit.plugins.health import HealthPlugin
17
18
  from prodkit.plugins.logging import LoggingPlugin
19
+ from prodkit.plugins.metrics import MetricsPlugin
18
20
  from prodkit.plugins.rate_limit import RateLimitPlugin
19
21
  from prodkit.plugins.request_id import RequestIDPlugin
20
22
  from prodkit.plugins.security import SecurityPlugin
23
+ from prodkit.plugins.tracing import TracingPlugin
21
24
 
22
25
  if TYPE_CHECKING:
23
26
  from prodkit.contracts.plugin import Plugin
@@ -25,13 +28,16 @@ if TYPE_CHECKING:
25
28
 
26
29
  __all__ = [
27
30
  "CORSPlugin",
31
+ "CachePlugin",
28
32
  "CompressionPlugin",
29
33
  "ErrorsPlugin",
30
34
  "HealthPlugin",
31
35
  "LoggingPlugin",
36
+ "MetricsPlugin",
32
37
  "RateLimitPlugin",
33
38
  "RequestIDPlugin",
34
39
  "SecurityPlugin",
40
+ "TracingPlugin",
35
41
  "builtin_plugins",
36
42
  ]
37
43
 
@@ -55,4 +61,10 @@ def builtin_plugins(config: ProdKitConfig) -> list[Plugin]:
55
61
  plugins.append(RateLimitPlugin())
56
62
  if config.compression.enabled:
57
63
  plugins.append(CompressionPlugin())
64
+ if config.metrics.enabled:
65
+ plugins.append(MetricsPlugin())
66
+ if config.cache.enabled:
67
+ plugins.append(CachePlugin())
68
+ if config.tracing.enabled:
69
+ plugins.append(TracingPlugin())
58
70
  return plugins
@@ -0,0 +1,34 @@
1
+ """Shared Redis client construction for plugins with a Redis backend.
2
+
3
+ The optional ``redis`` dependency is imported lazily here — plugin modules are
4
+ imported unconditionally by ``prodkit.plugins``, so a missing extra must not
5
+ break ``import prodkit``. It fails at plugin ``configure()`` time instead, with
6
+ an actionable message. This function is also the single seam tests monkeypatch
7
+ to inject a fake client (fakeredis).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from prodkit.core.exceptions import ProdKitConfigError
15
+
16
+ if TYPE_CHECKING:
17
+ from redis.asyncio import Redis
18
+
19
+
20
+ def create_client(url: str, *, section: str = "redis") -> Redis:
21
+ """Create an async Redis client, or fail with a named-section pip hint.
22
+
23
+ The client connects lazily (on first command), so calling this at boot is
24
+ cheap; reachability is verified by the owning plugin's ``startup()`` ping.
25
+ """
26
+ try:
27
+ from redis.asyncio import Redis
28
+ except ImportError:
29
+ raise ProdKitConfigError(
30
+ f"{section}: backend='redis' requires the redis package. "
31
+ "Install it with: pip install 'prodkit[redis]'"
32
+ ) from None
33
+ client: Any = Redis.from_url(url)
34
+ return client # type: ignore[no-any-return]
@@ -0,0 +1,150 @@
1
+ """Cache plugin: a named cache service other plugins and user code can share.
2
+
3
+ The service is published in the registry under ``"cache"``::
4
+
5
+ cache = ctx.registry.get("cache")
6
+ await cache.set("user:42", {"name": "Ada"}, ttl=60)
7
+ user = await cache.get("user:42")
8
+
9
+ Two backends: ``memory`` (per-process LRU with TTL, the default) and ``redis``
10
+ (shared across workers/hosts). Values must be JSON-serializable so behavior is
11
+ identical across backends.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from collections import OrderedDict
19
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol
20
+
21
+ from prodkit.contracts.plugin import Audit, Check, Plugin
22
+ from prodkit.core.context import Context
23
+
24
+ if TYPE_CHECKING:
25
+ from redis.asyncio import Redis
26
+
27
+
28
+ class CacheService(Protocol):
29
+ """The contract of the registry's ``"cache"`` service.
30
+
31
+ ``ttl=None`` means the configured default TTL; ``ttl=0`` means no expiry.
32
+ """
33
+
34
+ async def get(self, key: str) -> Any | None: ...
35
+
36
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None: ...
37
+
38
+ async def delete(self, key: str) -> None: ...
39
+
40
+
41
+ class MemoryCache:
42
+ """Per-process LRU cache with monotonic-deadline TTLs (lazy expiry)."""
43
+
44
+ def __init__(self, default_ttl: int, max_entries: int) -> None:
45
+ self.default_ttl = default_ttl
46
+ self.max_entries = max_entries
47
+ # key -> (deadline_monotonic | None, value); insertion order = LRU order
48
+ self._data: OrderedDict[str, tuple[float | None, Any]] = OrderedDict()
49
+
50
+ def _deadline(self, ttl: int | None) -> float | None:
51
+ effective = self.default_ttl if ttl is None else ttl
52
+ return None if effective == 0 else time.monotonic() + effective
53
+
54
+ async def get(self, key: str) -> Any | None:
55
+ item = self._data.get(key)
56
+ if item is None:
57
+ return None
58
+ deadline, value = item
59
+ if deadline is not None and time.monotonic() >= deadline:
60
+ del self._data[key]
61
+ return None
62
+ self._data.move_to_end(key)
63
+ return value
64
+
65
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
66
+ self._data[key] = (self._deadline(ttl), value)
67
+ self._data.move_to_end(key)
68
+ while len(self._data) > self.max_entries:
69
+ self._data.popitem(last=False) # evict least recently used
70
+
71
+ async def delete(self, key: str) -> None:
72
+ self._data.pop(key, None)
73
+
74
+
75
+ class RedisCache:
76
+ """Redis-backed cache; values JSON-encoded, keys prefixed."""
77
+
78
+ def __init__(self, url: str, default_ttl: int, prefix: str) -> None:
79
+ self.default_ttl = default_ttl
80
+ self.prefix = prefix
81
+ from prodkit.plugins._redis import create_client
82
+
83
+ self._client: Redis = create_client(url, section="cache")
84
+
85
+ async def get(self, key: str) -> Any | None:
86
+ raw = await self._client.get(self.prefix + key)
87
+ return None if raw is None else json.loads(raw)
88
+
89
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
90
+ effective = self.default_ttl if ttl is None else ttl
91
+ await self._client.set(
92
+ self.prefix + key,
93
+ json.dumps(value),
94
+ ex=effective if effective > 0 else None,
95
+ )
96
+
97
+ async def delete(self, key: str) -> None:
98
+ await self._client.delete(self.prefix + key)
99
+
100
+ async def ping(self) -> None:
101
+ await self._client.ping()
102
+
103
+ async def aclose(self) -> None:
104
+ await self._client.aclose()
105
+
106
+
107
+ class CachePlugin(Plugin):
108
+ name: ClassVar[str] = "cache"
109
+
110
+ def __init__(self) -> None:
111
+ self._cache: CacheService | None = None
112
+
113
+ def configure(self, ctx: Context) -> None:
114
+ cfg = ctx.config.cache
115
+ if cfg.backend == "redis":
116
+ # create_client raises a named ProdKitConfigError if the redis
117
+ # extra is missing; connection itself is verified in startup().
118
+ self._cache = RedisCache(cfg.redis_url, cfg.default_ttl, cfg.key_prefix)
119
+ else:
120
+ self._cache = MemoryCache(cfg.default_ttl, cfg.max_entries)
121
+ ctx.registry.provide("cache", self._cache)
122
+
123
+ async def startup(self, ctx: Context) -> None:
124
+ if isinstance(self._cache, RedisCache):
125
+ # Fail fast at boot if Redis is unreachable.
126
+ await self._cache.ping()
127
+
128
+ async def shutdown(self, ctx: Context) -> None:
129
+ if isinstance(self._cache, RedisCache):
130
+ await self._cache.aclose()
131
+
132
+ async def checks(self, ctx: Context) -> list[Check]:
133
+ if not isinstance(self._cache, RedisCache):
134
+ return []
135
+ try:
136
+ await self._cache.ping()
137
+ except Exception as exc:
138
+ return [Check(name="cache-redis", passed=False, detail=str(exc))]
139
+ return [Check(name="cache-redis", passed=True, detail="reachable")]
140
+
141
+ def doctor(self, ctx: Context) -> list[Audit]:
142
+ cfg = ctx.config.cache
143
+ return [
144
+ Audit(
145
+ name="Cache",
146
+ status="ok",
147
+ detail=f"{cfg.backend} backend, default_ttl={cfg.default_ttl}s",
148
+ weight=5,
149
+ )
150
+ ]