idemkit 0.1.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 (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. idemkit-0.1.0.dist-info/entry_points.txt +2 -0
idemkit/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ """idemkit command-line interface.
2
+
3
+ Commands:
4
+ idemkit init-pg <database-url> Create idemkit_records table + indexes.
5
+ idemkit pg-vacuum <database-url> Delete expired records.
6
+ idemkit conformance [--redis URL] [--postgres URL] [--mongo URL] [--dynamodb ENDPOINT]
7
+ Run the shared-core conformance vectors
8
+ against InMemory (always) and any backend
9
+ whose URL is given. Exits non-zero on any
10
+ failure.
11
+
12
+ ``init-pg`` and ``pg-vacuum`` are idempotent and safe to re-run.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import sys
20
+
21
+
22
+ def _make_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="idemkit",
25
+ description="idemkit operational tooling (schema migration, vacuum).",
26
+ )
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+
29
+ init_pg = sub.add_parser(
30
+ "init-pg",
31
+ help="Create the records table and indexes (idempotent).",
32
+ )
33
+ init_pg.add_argument("database_url", help="postgres:// URL.")
34
+ init_pg.add_argument(
35
+ "--table",
36
+ default="idemkit_records",
37
+ help="Records table name (default: idemkit_records).",
38
+ )
39
+
40
+ vacuum = sub.add_parser(
41
+ "pg-vacuum",
42
+ help="Delete expired records from the table.",
43
+ )
44
+ vacuum.add_argument("database_url", help="postgres:// URL.")
45
+ vacuum.add_argument(
46
+ "--table",
47
+ default="idemkit_records",
48
+ help="Records table name (default: idemkit_records).",
49
+ )
50
+ vacuum.add_argument(
51
+ "--expires-after-seconds",
52
+ type=float,
53
+ default=86_400.0,
54
+ help="Records COMPLETED before (now - this) are deleted (default: 24h).",
55
+ )
56
+ vacuum.add_argument(
57
+ "--lease-grace-seconds",
58
+ type=float,
59
+ default=60.0,
60
+ help="CLAIMED records with lease_until + grace < now are deleted (default: 60s).",
61
+ )
62
+
63
+ conformance = sub.add_parser(
64
+ "conformance",
65
+ help="Run the shared-core conformance vectors against backends.",
66
+ )
67
+ conformance.add_argument("--redis", help="redis:// URL to also run the vectors against.")
68
+ conformance.add_argument("--postgres", help="postgres:// URL to also run the vectors against.")
69
+ conformance.add_argument("--mongo", help="mongodb:// URL to also run the vectors against.")
70
+ conformance.add_argument(
71
+ "--dynamodb",
72
+ metavar="ENDPOINT",
73
+ help=(
74
+ "DynamoDB endpoint URL to also run the vectors against (e.g. "
75
+ "http://localhost:8000 for DynamoDB Local; credentials and region come "
76
+ "from the environment)."
77
+ ),
78
+ )
79
+
80
+ return parser
81
+
82
+
83
+ def main(argv: list[str] | None = None) -> int:
84
+ parser = _make_parser()
85
+ args = parser.parse_args(argv)
86
+
87
+ if args.command == "init-pg":
88
+ from idemkit.backends.postgres import init_pg
89
+
90
+ asyncio.run(init_pg(args.database_url, table=args.table))
91
+ print("idemkit: PostgreSQL schema initialized")
92
+ return 0
93
+
94
+ if args.command == "pg-vacuum":
95
+ from idemkit.backends.postgres import pg_vacuum
96
+
97
+ n = asyncio.run(
98
+ pg_vacuum(
99
+ args.database_url,
100
+ table=args.table,
101
+ expires_after_seconds=args.expires_after_seconds,
102
+ lease_grace_seconds=args.lease_grace_seconds,
103
+ )
104
+ )
105
+ print(f"idemkit: vacuumed {n} expired record(s)")
106
+ return 0
107
+
108
+ if args.command == "conformance":
109
+ return asyncio.run(_run_conformance(args.redis, args.postgres, args.mongo, args.dynamodb))
110
+
111
+ parser.print_help()
112
+ return 2
113
+
114
+
115
+ async def _run_conformance(
116
+ redis_url: str | None,
117
+ postgres_url: str | None,
118
+ mongo_url: str | None = None,
119
+ dynamodb_endpoint: str | None = None,
120
+ ) -> int:
121
+ """Run the conformance suite against InMemory plus any configured backend."""
122
+ from idemkit.backends.memory import InMemoryBackend
123
+ from idemkit.conformance import BackendConformance
124
+
125
+ all_passed = True
126
+
127
+ async def _run(backend: object, aclose: bool) -> None:
128
+ nonlocal all_passed
129
+ report = await BackendConformance(backend).run() # type: ignore[arg-type]
130
+ print(report.report())
131
+ if not report.passed:
132
+ all_passed = False
133
+ if aclose and hasattr(backend, "aclose"):
134
+ await backend.aclose()
135
+
136
+ await _run(InMemoryBackend(), aclose=False)
137
+
138
+ if redis_url:
139
+ from idemkit.backends.redis import RedisBackend
140
+
141
+ await _run(RedisBackend.from_url(redis_url), aclose=True)
142
+
143
+ if postgres_url:
144
+ from idemkit.backends.postgres import PostgresBackend, init_pg
145
+
146
+ await init_pg(postgres_url)
147
+ await _run(PostgresBackend.from_url(postgres_url), aclose=True)
148
+
149
+ if mongo_url:
150
+ from idemkit.backends.mongo import MongoBackend
151
+
152
+ await _run(MongoBackend.from_url(mongo_url), aclose=True)
153
+
154
+ if dynamodb_endpoint:
155
+ from idemkit.backends.dynamodb import DynamoBackend
156
+
157
+ await _run(DynamoBackend(endpoint_url=dynamodb_endpoint), aclose=True)
158
+
159
+ print()
160
+ print("idemkit: conformance PASSED" if all_passed else "idemkit: conformance FAILED")
161
+ return 0 if all_passed else 1
162
+
163
+
164
+ if __name__ == "__main__":
165
+ sys.exit(main())
@@ -0,0 +1,25 @@
1
+ """idemkit conformance suite — a public, reusable correctness standard (spec §11.1).
2
+
3
+ The shared-core vectors (atomic claim, fencing, lease reclaim, in-flight wait,
4
+ TTL expiry, lease renewal) packaged so ANY library whose backend implements the
5
+ ``IdempotencyBackend`` Protocol can validate itself against them, in or out of
6
+ idemkit. This is the artifact §11.1 calls for: a correctness suite the field can
7
+ reference, not a private test file.
8
+
9
+ from idemkit.conformance import BackendConformance
10
+
11
+ report = await BackendConformance(my_backend).run()
12
+ assert report.passed, report.report()
13
+
14
+ The language-neutral vector descriptions (for ports to other languages) live in
15
+ ``spec/conformance.yaml``; this package is the runnable Python reference.
16
+ """
17
+
18
+ from idemkit.conformance.backend import BackendConformance
19
+ from idemkit.conformance.report import ConformanceReport, VectorResult
20
+
21
+ __all__ = [
22
+ "BackendConformance",
23
+ "ConformanceReport",
24
+ "VectorResult",
25
+ ]
@@ -0,0 +1,257 @@
1
+ """Shared-core conformance vectors for the backend Protocol (spec §9).
2
+
3
+ These are the language-neutral correctness checks every ``IdempotencyBackend``
4
+ must pass — atomic claim, fencing, lease reclaim, in-flight wait, TTL expiry, and
5
+ lease renewal — packaged so ANY implementation can point them at itself, not just
6
+ idemkit's own three backends. That portability is the whole point of §11.1: a
7
+ public correctness suite the field can reference, rather than a private test file.
8
+
9
+ Usage from a third-party library whose backend implements the Protocol::
10
+
11
+ from idemkit.conformance import BackendConformance
12
+
13
+ report = await BackendConformance(MyBackend(...)).run()
14
+ assert report.passed, report.report()
15
+
16
+ The vectors use a unique random key each, so they are safe to run repeatedly
17
+ against a shared, persistent backend (e.g. one PostgreSQL database) without
18
+ cross-run interference. They exercise only the public Protocol — backend-specific
19
+ behavior like corrupt-record recovery is tested inside idemkit itself, since it
20
+ needs internals a generic suite can't reach.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import uuid
27
+ from collections import Counter
28
+ from collections.abc import Awaitable, Callable
29
+
30
+ from idemkit.backends.base import IdempotencyBackend
31
+ from idemkit.conformance.report import ConformanceReport, VectorResult
32
+ from idemkit.core.state import ClaimResultType, State
33
+
34
+
35
+ class BackendConformance:
36
+ """Runs the shared-core vectors against one backend instance."""
37
+
38
+ def __init__(self, backend: IdempotencyBackend, *, supports_renew: bool = True) -> None:
39
+ self.backend = backend
40
+ self.supports_renew = supports_renew
41
+
42
+ async def run(self) -> ConformanceReport:
43
+ """Run every applicable vector and collect the results."""
44
+ report = ConformanceReport(backend_name=type(self.backend).__name__)
45
+ for vector_id, description, fn in self._vectors():
46
+ try:
47
+ await fn()
48
+ report.results.append(VectorResult(vector_id, True, description))
49
+ except AssertionError as e:
50
+ report.results.append(
51
+ VectorResult(vector_id, False, description, error=f"assertion: {e}")
52
+ )
53
+ except Exception as e:
54
+ # A vector that errors (not just asserts) is still a failure, not
55
+ # a crash of the whole run — record it and keep going.
56
+ report.results.append(
57
+ VectorResult(vector_id, False, description, error=f"{type(e).__name__}: {e}")
58
+ )
59
+ return report
60
+
61
+ def _vectors(self) -> list[tuple[str, str, Callable[[], Awaitable[None]]]]:
62
+ vectors: list[tuple[str, str, Callable[[], Awaitable[None]]]] = [
63
+ ("atomic-claim", "a fresh key claims NEW with a token", self._atomic_claim),
64
+ (
65
+ "duplicate-claim",
66
+ "a second claim on a live key returns ALREADY_CLAIMED",
67
+ self._duplicate_claim,
68
+ ),
69
+ (
70
+ "conditional-complete",
71
+ "complete transitions CLAIMED -> COMPLETED and replays",
72
+ self._conditional_complete,
73
+ ),
74
+ (
75
+ "fencing-wrong-token",
76
+ "complete with a wrong token is rejected",
77
+ self._fencing_wrong_token,
78
+ ),
79
+ (
80
+ "release-clears-claim",
81
+ "release with the right token frees the key",
82
+ self._release_clears,
83
+ ),
84
+ (
85
+ "lease-reclaim",
86
+ "an expired lease is reclaimable with a new token",
87
+ self._lease_reclaim,
88
+ ),
89
+ (
90
+ "fenced-after-reclaim",
91
+ "a reclaimed owner's late completion is rejected",
92
+ self._fenced_after_reclaim,
93
+ ),
94
+ (
95
+ "concurrent-claim-exactly-one",
96
+ "N concurrent claims yield exactly one NEW",
97
+ self._concurrent_claim,
98
+ ),
99
+ (
100
+ "in-flight-wait",
101
+ "a waiter receives the completed record",
102
+ self._in_flight_wait,
103
+ ),
104
+ (
105
+ "wait-timeout",
106
+ "a waiter times out when the claim never completes",
107
+ self._wait_timeout,
108
+ ),
109
+ (
110
+ "completed-ttl-expiry",
111
+ "a COMPLETED record past its TTL re-claims fresh",
112
+ self._completed_ttl_expiry,
113
+ ),
114
+ ]
115
+ if self.supports_renew:
116
+ vectors.extend(
117
+ [
118
+ (
119
+ "renew-extends-lease",
120
+ "renew pushes out a live lease so it isn't reclaimed",
121
+ self._renew_extends,
122
+ ),
123
+ (
124
+ "renew-fenced",
125
+ "renew with a wrong token, or after reclaim, is rejected",
126
+ self._renew_fenced,
127
+ ),
128
+ ]
129
+ )
130
+ return vectors
131
+
132
+ # ----- the vectors -----
133
+
134
+ async def _atomic_claim(self) -> None:
135
+ key = _k("atomic")
136
+ result = await self.backend.claim(key, "fp", 1, 30.0)
137
+ assert result.result == ClaimResultType.NEW_CLAIMED
138
+ assert result.our_claim_token is not None
139
+ assert result.record.state == State.CLAIMED
140
+
141
+ async def _duplicate_claim(self) -> None:
142
+ key = _k("dup")
143
+ first = await self.backend.claim(key, "fp", 1, 30.0)
144
+ second = await self.backend.claim(key, "fp", 1, 30.0)
145
+ assert second.result == ClaimResultType.ALREADY_CLAIMED
146
+ assert second.record.claim_token == first.our_claim_token
147
+
148
+ async def _conditional_complete(self) -> None:
149
+ key = _k("complete")
150
+ first = await self.backend.claim(key, "fp", 1, 30.0)
151
+ token = _token(first)
152
+ ok = await self.backend.complete(key, token, 201, {"h": "v"}, b"body", 3600.0)
153
+ assert ok is True
154
+ after = await self.backend.claim(key, "fp", 1, 30.0)
155
+ assert after.result == ClaimResultType.ALREADY_COMPLETED
156
+ assert after.record.response_status == 201
157
+ assert after.record.response_body == b"body"
158
+
159
+ async def _fencing_wrong_token(self) -> None:
160
+ key = _k("fence")
161
+ await self.backend.claim(key, "fp", 1, 30.0)
162
+ ok = await self.backend.complete(key, "not-the-token", 200, {}, b"x", 3600.0)
163
+ assert ok is False
164
+
165
+ async def _release_clears(self) -> None:
166
+ key = _k("release")
167
+ first = await self.backend.claim(key, "fp", 1, 30.0)
168
+ assert await self.backend.release(key, _token(first)) is True
169
+ after = await self.backend.claim(key, "fp", 1, 30.0)
170
+ assert after.result == ClaimResultType.NEW_CLAIMED
171
+
172
+ async def _lease_reclaim(self) -> None:
173
+ key = _k("reclaim")
174
+ first = await self.backend.claim(key, "fp", 1, 0.1)
175
+ await asyncio.sleep(0.25)
176
+ second = await self.backend.claim(key, "fp", 1, 30.0)
177
+ assert second.result == ClaimResultType.LEASE_RECLAIMED
178
+ assert second.our_claim_token != first.our_claim_token
179
+
180
+ async def _fenced_after_reclaim(self) -> None:
181
+ key = _k("fenced-reclaim")
182
+ first = await self.backend.claim(key, "fp", 1, 0.1)
183
+ await asyncio.sleep(0.25)
184
+ second = await self.backend.claim(key, "fp", 1, 30.0)
185
+ assert second.result == ClaimResultType.LEASE_RECLAIMED
186
+ ok = await self.backend.complete(key, _token(first), 200, {}, b"x", 3600.0)
187
+ assert ok is False
188
+ after = await self.backend.claim(key, "fp", 1, 30.0)
189
+ assert after.record.claim_token == second.our_claim_token
190
+
191
+ async def _concurrent_claim(self) -> None:
192
+ key = _k("concurrent")
193
+ parallel = 25
194
+ results = await asyncio.gather(
195
+ *(self.backend.claim(key, "fp", 1, 30.0) for _ in range(parallel))
196
+ )
197
+ outcomes = Counter(r.result for r in results)
198
+ assert outcomes[ClaimResultType.NEW_CLAIMED] == 1
199
+ assert outcomes[ClaimResultType.ALREADY_CLAIMED] == parallel - 1
200
+
201
+ async def _in_flight_wait(self) -> None:
202
+ key = _k("wait")
203
+ first = await self.backend.claim(key, "fp", 1, 30.0)
204
+ token = _token(first)
205
+
206
+ async def completer() -> None:
207
+ await asyncio.sleep(0.1)
208
+ await self.backend.complete(key, token, 200, {}, b"hi", 3600.0)
209
+
210
+ waited, _ = await asyncio.gather(
211
+ self.backend.wait_for_completion(key, timeout_seconds=2.0), completer()
212
+ )
213
+ assert waited is not None
214
+ assert waited.state == State.COMPLETED
215
+ assert waited.response_body == b"hi"
216
+
217
+ async def _wait_timeout(self) -> None:
218
+ key = _k("wait-timeout")
219
+ await self.backend.claim(key, "fp", 1, 30.0)
220
+ result = await self.backend.wait_for_completion(key, timeout_seconds=0.3)
221
+ assert result is None
222
+
223
+ async def _completed_ttl_expiry(self) -> None:
224
+ # The TTL window must comfortably outlast a backend round-trip, or a slow
225
+ # store can expire the record before the "still completed" check below.
226
+ key = _k("ttl")
227
+ first = await self.backend.claim(key, "fp", 1, 30.0)
228
+ await self.backend.complete(key, _token(first), 200, {}, b"x", 1.0)
229
+ immediate = await self.backend.claim(key, "fp", 1, 30.0)
230
+ assert immediate.result == ClaimResultType.ALREADY_COMPLETED
231
+ await asyncio.sleep(1.5)
232
+ after = await self.backend.claim(key, "fp", 1, 30.0)
233
+ assert after.result == ClaimResultType.NEW_CLAIMED
234
+
235
+ async def _renew_extends(self) -> None:
236
+ key = _k("renew")
237
+ first = await self.backend.claim(key, "fp", 1, 0.2)
238
+ assert await self.backend.renew(key, _token(first), 30.0) is True
239
+ await asyncio.sleep(0.4)
240
+ second = await self.backend.claim(key, "fp", 1, 30.0)
241
+ assert second.result == ClaimResultType.ALREADY_CLAIMED
242
+
243
+ async def _renew_fenced(self) -> None:
244
+ key = _k("renew-fenced")
245
+ await self.backend.claim(key, "fp", 1, 30.0)
246
+ assert await self.backend.renew(key, "not-the-token", 30.0) is False
247
+
248
+
249
+ def _token(result: object) -> str:
250
+ """The claim token from a NEW/RECLAIMED claim, asserted present."""
251
+ token = getattr(result, "our_claim_token", None)
252
+ assert token is not None, "a NEW/RECLAIMED claim must carry a claim token"
253
+ return str(token)
254
+
255
+
256
+ def _k(suffix: str) -> str:
257
+ return f"conformance-{suffix}-{uuid.uuid4().hex}"
@@ -0,0 +1,54 @@
1
+ """Result types for the conformance runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class VectorResult:
10
+ """The outcome of one conformance vector."""
11
+
12
+ vector_id: str
13
+ passed: bool
14
+ description: str
15
+ error: str | None = None
16
+
17
+ @property
18
+ def status(self) -> str:
19
+ return "PASS" if self.passed else "FAIL"
20
+
21
+
22
+ @dataclass
23
+ class ConformanceReport:
24
+ """The result of running a set of vectors against one backend."""
25
+
26
+ backend_name: str
27
+ results: list[VectorResult] = field(default_factory=list)
28
+
29
+ @property
30
+ def passed(self) -> bool:
31
+ """True only if every vector passed."""
32
+ return all(r.passed for r in self.results)
33
+
34
+ @property
35
+ def total(self) -> int:
36
+ return len(self.results)
37
+
38
+ @property
39
+ def failures(self) -> list[VectorResult]:
40
+ return [r for r in self.results if not r.passed]
41
+
42
+ def summary(self) -> str:
43
+ """One-line human summary, e.g. ``RedisBackend: 13/13 vectors passed``."""
44
+ passed = sum(1 for r in self.results if r.passed)
45
+ return f"{self.backend_name}: {passed}/{self.total} vectors passed"
46
+
47
+ def report(self) -> str:
48
+ """A multi-line report listing each vector's status."""
49
+ lines = [self.summary()]
50
+ for r in self.results:
51
+ lines.append(f" [{r.status}] {r.vector_id} — {r.description}")
52
+ if r.error:
53
+ lines.append(f" {r.error}")
54
+ return "\n".join(lines)
@@ -0,0 +1,30 @@
1
+ """Optional broker glue for idemkit's queue surface.
2
+
3
+ The core queue adapter (:class:`idemkit.IdempotentConsumer`) is broker-agnostic —
4
+ you supply small callables that read the dedup id, scope, and receive count from
5
+ whatever message object your library hands you. That is deliberately flexible but
6
+ means everyone rewrites the same wiring for the same two or three brokers.
7
+
8
+ ``idemkit.contrib`` presets that wiring for the common cases so it's import-and-go:
9
+
10
+ * :mod:`idemkit.contrib.sqs` — Amazon SQS (boto3 message dicts).
11
+ * :mod:`idemkit.contrib.kafka` — Kafka (``confluent_kafka`` or ``kafka-python`` records).
12
+ * :mod:`idemkit.contrib.mcp` — enforce idempotency on MCP / LLM tool calls.
13
+ * :mod:`idemkit.contrib.fastapi` — a FastAPI route class (return a dict, read
14
+ ``request.state`` in ``scope``).
15
+ * :mod:`idemkit.contrib.drf` — a Django REST Framework view mixin (read
16
+ ``request.user`` in ``scope``).
17
+
18
+ For Celery there is no separate module: a task body is a plain synchronous
19
+ function, so :func:`idemkit.idempotent_sync` is the tool (stack it UNDER
20
+ ``@app.task``). See ``examples/method/sync_function.py``.
21
+
22
+ None of these import a broker SDK; they read duck-typed message objects and (for
23
+ SQS) take the client you already created. Install the broker library yourself.
24
+
25
+ For observability there are two ready-made event handlers, so you don't hand-roll
26
+ the metric plumbing:
27
+
28
+ * :mod:`idemkit.contrib.prometheus` — record operations / latency to Prometheus.
29
+ * :mod:`idemkit.contrib.logging` — one structured log line per operation.
30
+ """