svc-infra 0.1.595__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,58 +1,113 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from enum import Enum
4
+ from typing import Any, cast
4
5
 
5
6
  from ai_infra.llm.tools.custom.cli import cli_cmd_help, cli_subcmd_help
6
7
  from ai_infra.mcp.server.tools import mcp_from_functions
7
8
 
9
+ from svc_infra.app.env import prepare_env
10
+ from svc_infra.cli.foundation.runner import run_from_root
11
+
8
12
  CLI_PROG = "svc-infra"
9
13
 
10
14
 
11
- async def svc_infra_cmd_help() -> dict:
15
+ async def svc_infra_cmd_help() -> dict[Any, Any]:
12
16
  """
13
17
  Get help text for svc-infra CLI.
14
18
  - Prepares project env without chdir (so we can 'cd' in the command itself).
15
19
  - Tries poetry → console script → python -m svc_infra.cli_shim.
16
20
  """
17
- return await cli_cmd_help(CLI_PROG)
21
+ return cast("dict[Any, Any]", await cli_cmd_help(CLI_PROG))
22
+
23
+
24
+ # No dedicated 'docs list' function — users can use 'docs --help' to discover topics.
25
+
26
+
27
+ async def svc_infra_docs_help() -> dict:
28
+ """
29
+ Run 'svc-infra docs --help' and return its output.
30
+ Prepares the project environment and executes from the repo root so
31
+ environment-provided docs directories and local topics are discoverable.
32
+ """
33
+ root = prepare_env()
34
+ text = await run_from_root(root, CLI_PROG, ["docs", "--help"])
35
+ return {
36
+ "ok": True,
37
+ "action": "docs_help",
38
+ "project_root": str(root),
39
+ "help": text,
40
+ }
18
41
 
19
42
 
20
43
  class Subcommand(str, Enum):
21
- # SQL commands
22
- sql_init = "sql-init"
23
- sql_revision = "sql-revision"
24
- sql_upgrade = "sql-upgrade"
25
- sql_downgrade = "sql-downgrade"
26
- sql_current = "sql-current"
27
- sql_history = "sql-history"
28
- sql_stamp = "sql-stamp"
29
- sql_merge_heads = "sql-merge-heads"
30
- sql_setup_and_migrate = "sql-setup-and-migrate"
31
- sql_scaffold = "sql-scaffold"
32
- sql_scaffold_models = "sql-scaffold-models"
33
- sql_scaffold_schemas = "sql-scaffold-schemas"
34
-
35
- # Mongo commands
36
- mongo_prepare = "mongo-prepare"
37
- mongo_setup_and_prepare = "mongo-setup-and-prepare"
38
- mongo_ping = "mongo-ping"
39
- mongo_scaffold = "mongo-scaffold"
40
- mongo_scaffold_documents = "mongo-scaffold-documents"
41
- mongo_scaffold_schemas = "mongo-scaffold-schemas"
42
- mongo_scaffold_resources = "mongo-scaffold-resources"
43
-
44
- # Observability commands
45
- obs_up = "obs-up"
46
- obs_down = "obs-down"
47
- obs_scaffold = "obs-scaffold"
48
-
49
-
50
- async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict:
44
+ # SQL group commands
45
+ sql_init = "sql init"
46
+ sql_revision = "sql revision"
47
+ sql_upgrade = "sql upgrade"
48
+ sql_downgrade = "sql downgrade"
49
+ sql_current = "sql current"
50
+ sql_history = "sql history"
51
+ sql_stamp = "sql stamp"
52
+ sql_merge_heads = "sql merge-heads"
53
+ sql_setup_and_migrate = "sql setup-and-migrate"
54
+ sql_scaffold = "sql scaffold"
55
+ sql_scaffold_models = "sql scaffold-models"
56
+ sql_scaffold_schemas = "sql scaffold-schemas"
57
+ sql_export_tenant = "sql export-tenant"
58
+ sql_seed = "sql seed"
59
+
60
+ # Mongo group commands
61
+ mongo_prepare = "mongo prepare"
62
+ mongo_setup_and_prepare = "mongo setup-and-prepare"
63
+ mongo_ping = "mongo ping"
64
+ mongo_scaffold = "mongo scaffold"
65
+ mongo_scaffold_documents = "mongo scaffold-documents"
66
+ mongo_scaffold_schemas = "mongo scaffold-schemas"
67
+ mongo_scaffold_resources = "mongo scaffold-resources"
68
+
69
+ # Observability group commands
70
+ obs_up = "obs up"
71
+ obs_down = "obs down"
72
+ obs_scaffold = "obs scaffold"
73
+
74
+ # Docs group
75
+ docs_help = "docs --help"
76
+ docs_show = "docs show"
77
+
78
+ # DX group
79
+ dx_openapi = "dx openapi"
80
+ dx_migrations = "dx migrations"
81
+ dx_changelog = "dx changelog"
82
+ dx_ci = "dx ci"
83
+
84
+ # Jobs group
85
+ jobs_run = "jobs run"
86
+
87
+ # SDK group
88
+ sdk_ts = "sdk ts"
89
+ sdk_py = "sdk py"
90
+ sdk_postman = "sdk postman"
91
+
92
+
93
+ async def svc_infra_subcmd_help(subcommand: Subcommand) -> dict[Any, Any]:
51
94
  """
52
95
  Get help text for a specific subcommand of svc-infra CLI.
53
96
  (Enum keeps a tight schema; function signature remains simple.)
54
97
  """
55
- return await cli_subcmd_help(CLI_PROG, subcommand)
98
+ tokens = subcommand.value.split()
99
+ if len(tokens) == 1:
100
+ return cast("dict[Any, Any]", await cli_subcmd_help(CLI_PROG, subcommand))
101
+
102
+ root = prepare_env()
103
+ text = await run_from_root(root, CLI_PROG, [*tokens, "--help"])
104
+ return {
105
+ "ok": True,
106
+ "action": "subcommand_help",
107
+ "subcommand": subcommand.value,
108
+ "project_root": str(root),
109
+ "help": text,
110
+ }
56
111
 
57
112
 
58
113
  mcp = mcp_from_functions(
@@ -60,8 +115,11 @@ mcp = mcp_from_functions(
60
115
  functions=[
61
116
  svc_infra_cmd_help,
62
117
  svc_infra_subcmd_help,
118
+ svc_infra_docs_help,
119
+ # Docs listing is available via 'docs --help'; no separate MCP function needed.
63
120
  ],
64
121
  )
65
122
 
123
+
66
124
  if __name__ == "__main__":
67
125
  mcp.run(transport="stdio")
svc_infra/obs/README.md CHANGED
@@ -8,6 +8,8 @@ This guide shows you how to turn on metrics + dashboards in three easy modes:
8
8
 
9
9
  It's "one button": run `svc-infra obs-up` and you're good. The CLI will read your `.env` automatically and do the right thing.
10
10
 
11
+ > ℹ️ A complete list of observability-related environment variables lives in [Environment Reference](../../../docs/environment.md).
12
+
11
13
  ---
12
14
 
13
15
  ## 0) Install & instrument your app (once)
svc_infra/obs/add.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Iterable, Optional
3
+ from collections.abc import Callable, Iterable
4
+ from typing import Any, Protocol
4
5
 
5
6
  from svc_infra.obs.settings import ObservabilitySettings
6
7
 
@@ -9,12 +10,20 @@ def _want_metrics(cfg: ObservabilitySettings) -> bool:
9
10
  return bool(cfg.METRICS_ENABLED)
10
11
 
11
12
 
13
+ class RouteClassifier(Protocol):
14
+ def __call__(
15
+ self, route_path: str, method: str
16
+ ) -> str: # e.g., returns "public|internal|admin"
17
+ ...
18
+
19
+
12
20
  def add_observability(
13
21
  app: Any | None = None,
14
22
  *,
15
- db_engines: Optional[Iterable[Any]] = None,
23
+ db_engines: Iterable[Any] | None = None,
16
24
  metrics_path: str | None = None,
17
- skip_metric_paths: Optional[Iterable[str]] = None,
25
+ skip_metric_paths: Iterable[str] | None = None,
26
+ route_classifier: RouteClassifier | None = None,
18
27
  ) -> Callable[[], None]:
19
28
  """
20
29
  Enable Prometheus metrics for the ASGI app and optional SQLAlchemy pool metrics.
@@ -25,21 +34,66 @@ def add_observability(
25
34
  # --- Metrics (Prometheus) — import lazily so CLIs/tests don’t require prometheus_client
26
35
  if app is not None and _want_metrics(cfg):
27
36
  try:
28
- from svc_infra.obs.metrics.asgi import add_prometheus # lazy
37
+ from svc_infra.obs.metrics.asgi import ( # lazy
38
+ PrometheusMiddleware,
39
+ add_prometheus,
40
+ metrics_endpoint,
41
+ )
29
42
 
30
43
  path = metrics_path or cfg.METRICS_PATH
31
- add_prometheus(
32
- app,
33
- path=path,
34
- skip_paths=tuple(skip_metric_paths or (path, "/health", "/healthz")),
35
- )
44
+ skip_paths = tuple(skip_metric_paths or (path, "/health", "/healthz"))
45
+ # If a route_classifier is provided, use a custom route_resolver to append class label
46
+ if route_classifier is None:
47
+ add_prometheus(
48
+ app,
49
+ path=path,
50
+ skip_paths=skip_paths,
51
+ )
52
+ else:
53
+ # Install middleware manually to pass route_resolver
54
+ def _resolver(req):
55
+ # Base template
56
+ from svc_infra.obs.metrics.asgi import _route_template
57
+
58
+ base = _route_template(req)
59
+ method = getattr(req, "method", "GET")
60
+ cls = route_classifier(base, method)
61
+ # Encode as base|class for downstream label splitting in dashboards
62
+ return f"{base}|{cls}"
63
+
64
+ app.add_middleware(
65
+ PrometheusMiddleware,
66
+ skip_paths=skip_paths,
67
+ route_resolver=_resolver,
68
+ )
69
+ # Mount /metrics endpoint without re-adding middleware
70
+ try:
71
+ from svc_infra.api.fastapi.dual.public import public_router
72
+ from svc_infra.app.env import (
73
+ CURRENT_ENVIRONMENT,
74
+ DEV_ENV,
75
+ LOCAL_ENV,
76
+ )
77
+
78
+ router = public_router()
79
+ router.add_api_route(
80
+ path,
81
+ endpoint=metrics_endpoint(),
82
+ include_in_schema=CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV),
83
+ tags=["observability"],
84
+ )
85
+ app.include_router(router)
86
+ except Exception:
87
+ app.add_route(path, metrics_endpoint())
36
88
  except Exception:
37
89
  pass
38
90
 
39
91
  # --- DB pool metrics (best effort) — also lazy
40
92
  if db_engines:
41
93
  try:
42
- from svc_infra.obs.metrics.sqlalchemy import bind_sqlalchemy_pool_metrics # lazy
94
+ from svc_infra.obs.metrics.sqlalchemy import (
95
+ bind_sqlalchemy_pool_metrics,
96
+ ) # lazy
43
97
 
44
98
  for eng in db_engines:
45
99
  try:
@@ -51,7 +105,10 @@ def add_observability(
51
105
 
52
106
  # --- HTTP client metrics (best effort) — import lazily
53
107
  try:
54
- from svc_infra.obs.metrics.http import instrument_httpx, instrument_requests # lazy
108
+ from svc_infra.obs.metrics.http import (
109
+ instrument_httpx,
110
+ instrument_requests,
111
+ ) # lazy
55
112
 
56
113
  try:
57
114
  instrument_requests()
@@ -5,6 +5,7 @@ import os
5
5
  import re
6
6
  import urllib.request
7
7
  from importlib.resources import files
8
+ from typing import Any, cast
8
9
 
9
10
  # ---------------- helpers ----------------
10
11
 
@@ -86,7 +87,7 @@ def _rewrite_rate_windows(d: dict) -> dict:
86
87
  if not win:
87
88
  return d
88
89
 
89
- dd = json.loads(json.dumps(d))
90
+ dd = cast("dict[Any, Any]", json.loads(json.dumps(d)))
90
91
  for p in dd.get("panels", []) or []:
91
92
  targets = p.get("targets") or []
92
93
  for t in targets:
@@ -0,0 +1,45 @@
1
+ {
2
+ "title": "Service HTTP Overview",
3
+ "tags": ["svc-infra", "http"],
4
+ "timezone": "browser",
5
+ "panels": [
6
+ {
7
+ "type": "timeseries",
8
+ "title": "Success Rate (5m)",
9
+ "targets": [
10
+ {
11
+ "expr": "sum(rate(http_server_requests_total{code!~\"5..\"}[5m])) / sum(rate(http_server_requests_total[5m]))",
12
+ "legendFormat": "success_rate"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "type": "timeseries",
18
+ "title": "Latency p99",
19
+ "targets": [
20
+ {
21
+ "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le))",
22
+ "legendFormat": "p99"
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "type": "table",
28
+ "title": "Top Routes by Error (5m)",
29
+ "targets": [
30
+ {
31
+ "expr": "topk(10, sum(rate(http_server_requests_total{code=~\"5..\"}[5m])) by (route))",
32
+ "legendFormat": "{{route}}"
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "templating": {
38
+ "list": []
39
+ },
40
+ "time": {
41
+ "from": "now-6h",
42
+ "to": "now"
43
+ },
44
+ "refresh": "30s"
45
+ }
@@ -1,13 +1,12 @@
1
- from __future__ import annotations
2
-
3
- """
4
- Metrics package public API.
1
+ """Metrics package public API.
5
2
 
6
3
  Provides lightweight, overridable hooks for abuse heuristics so callers can
7
4
  plug in logging or a metrics backend without a hard dependency.
8
5
  """
9
6
 
10
- from typing import Callable, Optional
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
11
10
 
12
11
  # Function variables so applications/tests can replace them at runtime.
13
12
  on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
@@ -19,7 +18,7 @@ Args:
19
18
  retry_after: seconds until next allowed attempt
20
19
  """
21
20
 
22
- on_suspect_payload: Callable[[Optional[str], int], None] | None = None
21
+ on_suspect_payload: Callable[[str | None, int], None] | None = None
23
22
  """
24
23
  Called when a request exceeds the configured size limit.
25
24
  Args:
@@ -37,7 +36,7 @@ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
37
36
  pass
38
37
 
39
38
 
40
- def emit_suspect_payload(path: Optional[str], size: int) -> None:
39
+ def emit_suspect_payload(path: str | None, size: int) -> None:
41
40
  if on_suspect_payload:
42
41
  try:
43
42
  on_suspect_payload(path, size)
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import time
5
- from typing import Any, Callable, Iterable, Optional
5
+ from collections.abc import Callable, Iterable
6
+ from typing import Any, cast
6
7
 
7
8
  from starlette.requests import Request
8
9
  from starlette.responses import PlainTextResponse, Response
@@ -34,7 +35,7 @@ def _register_default_collectors_once() -> None:
34
35
  try:
35
36
  # These imports are no-ops if already registered by the client,
36
37
  # but GCCollector typically needs explicit instantiation.
37
- from prometheus_client import GCCollector # type: ignore
38
+ from prometheus_client import GCCollector
38
39
 
39
40
  GCCollector() # registers GC metrics
40
41
  _default_collectors_ready = True
@@ -97,9 +98,9 @@ def _init_metrics() -> None:
97
98
  def _route_template(req: Request) -> str:
98
99
  route = getattr(req, "scope", {}).get("route")
99
100
  if route and hasattr(route, "path_format"):
100
- return route.path_format
101
+ return cast("str", route.path_format)
101
102
  if route and hasattr(route, "path"):
102
- return route.path
103
+ return cast("str", route.path)
103
104
  return req.url.path or "/*unmatched*"
104
105
 
105
106
 
@@ -115,8 +116,8 @@ class PrometheusMiddleware:
115
116
  self,
116
117
  app: ASGIApp,
117
118
  *,
118
- skip_paths: Optional[Iterable[str]] = None,
119
- route_resolver: Optional[Callable[[Request], str]] = None,
119
+ skip_paths: Iterable[str] | None = None,
120
+ route_resolver: Callable[[Request], str] | None = None,
120
121
  ):
121
122
  self.app = app
122
123
  self.skip_paths = tuple(skip_paths or ("/metrics",))
@@ -237,7 +238,7 @@ def metrics_endpoint():
237
238
  return handler
238
239
 
239
240
 
240
- def add_prometheus(app, *, path: str = "/metrics", skip_paths: Optional[Iterable[str]] = None):
241
+ def add_prometheus(app, *, path: str = "/metrics", skip_paths: Iterable[str] | None = None):
241
242
  """Convenience for FastAPI/Starlette apps."""
242
243
  # Add middleware
243
244
  app.add_middleware(
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib
4
- from typing import Iterable, Optional
4
+ from collections.abc import Iterable
5
5
 
6
6
 
7
7
  class _MissingPrometheus(Exception):
@@ -31,8 +31,8 @@ def registry():
31
31
  import os
32
32
 
33
33
  prom = _prom_mod()
34
- REGISTRY = getattr(prom, "REGISTRY")
35
- CollectorRegistry = getattr(prom, "CollectorRegistry")
34
+ REGISTRY = prom.REGISTRY
35
+ CollectorRegistry = prom.CollectorRegistry
36
36
  multiprocess = getattr(prom, "multiprocess", None)
37
37
 
38
38
  if os.environ.get("PROMETHEUS_MULTIPROC_DIR") and multiprocess is not None:
@@ -46,14 +46,14 @@ def _mk_metric(
46
46
  ctor_name: str,
47
47
  name: str,
48
48
  doc: str,
49
- labels: Optional[Iterable[str]] = None,
49
+ labels: Iterable[str] | None = None,
50
50
  **kwargs,
51
51
  ):
52
52
  prom = _prom_mod()
53
- Counter = getattr(prom, "Counter")
54
- Gauge = getattr(prom, "Gauge")
55
- Histogram = getattr(prom, "Histogram")
56
- Summary = getattr(prom, "Summary")
53
+ Counter = prom.Counter
54
+ Gauge = prom.Gauge
55
+ Histogram = prom.Histogram
56
+ Summary = prom.Summary
57
57
 
58
58
  ctors = {
59
59
  "Counter": Counter,
@@ -67,11 +67,11 @@ def _mk_metric(
67
67
  return metric
68
68
 
69
69
 
70
- def counter(name: str, doc: str, labels: Optional[Iterable[str]] = None):
70
+ def counter(name: str, doc: str, labels: Iterable[str] | None = None):
71
71
  return _mk_metric("Counter", name, doc, labels)
72
72
 
73
73
 
74
- def gauge(name: str, doc: str, labels: Optional[Iterable[str]] = None, **kw):
74
+ def gauge(name: str, doc: str, labels: Iterable[str] | None = None, **kw):
75
75
  # e.g. gauge(..., multiprocess_mode="livesum")
76
76
  return _mk_metric("Gauge", name, doc, labels, **kw)
77
77
 
@@ -79,8 +79,8 @@ def gauge(name: str, doc: str, labels: Optional[Iterable[str]] = None, **kw):
79
79
  def histogram(
80
80
  name: str,
81
81
  doc: str,
82
- labels: Optional[Iterable[str]] = None,
83
- buckets: Optional[Iterable[float]] = None,
82
+ labels: Iterable[str] | None = None,
83
+ buckets: Iterable[float] | None = None,
84
84
  ):
85
85
  kwargs = {"buckets": list(buckets) if buckets else None}
86
86
  # Remove None so prometheus-client uses its defaults
@@ -88,5 +88,5 @@ def histogram(
88
88
  return _mk_metric("Histogram", name, doc, labels, **kwargs)
89
89
 
90
90
 
91
- def summary(name: str, doc: str, labels: Optional[Iterable[str]] = None):
91
+ def summary(name: str, doc: str, labels: Iterable[str] | None = None):
92
92
  return _mk_metric("Summary", name, doc, labels)
@@ -51,7 +51,7 @@ def instrument_requests():
51
51
  _http_client_total.labels(host, method_u, code).inc()
52
52
  _http_client_duration.labels(host, method_u).observe(elapsed)
53
53
 
54
- requests.sessions.Session.request = _wrapped # type: ignore[attr-defined]
54
+ requests.sessions.Session.request = _wrapped # type: ignore[method-assign]
55
55
 
56
56
 
57
57
  def instrument_httpx():
@@ -93,5 +93,5 @@ def instrument_httpx():
93
93
  _http_client_total.labels(host, method, code).inc()
94
94
  _http_client_duration.labels(host, method).observe(time.perf_counter() - start)
95
95
 
96
- httpx.Client.send = _wrap_sync_send(_orig_sync) # type: ignore[assignment]
97
- httpx.AsyncClient.send = _wrapped_async # type: ignore[assignment]
96
+ httpx.Client.send = _wrap_sync_send(_orig_sync) # type: ignore[method-assign]
97
+ httpx.AsyncClient.send = _wrapped_async # type: ignore[method-assign]
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Mapping, Optional
3
+ from collections.abc import Mapping
4
+ from typing import Any
4
5
 
5
6
  from sqlalchemy.engine import Engine
6
7
 
7
8
  try:
8
9
  from sqlalchemy.ext.asyncio import AsyncEngine
9
10
  except Exception: # optional
10
- AsyncEngine = None # type: ignore
11
+ AsyncEngine = None # type: ignore[misc,assignment]
11
12
 
12
13
  from .base import counter, gauge
13
14
 
@@ -27,12 +28,12 @@ _pool_checked_out_total = counter("db_pool_checkedout_total", "Total checkouts",
27
28
  _pool_checked_in_total = counter("db_pool_checkedin_total", "Total checkins", labels=["db"])
28
29
 
29
30
 
30
- def _label(labels: Optional[Mapping[str, str]]) -> str:
31
+ def _label(labels: Mapping[str, str] | None) -> str:
31
32
  return (labels or {}).get("db", "default")
32
33
 
33
34
 
34
35
  def bind_sqlalchemy_pool_metrics(
35
- engine: Engine | Any, labels: Optional[Mapping[str, str]] = None
36
+ engine: Engine | Any, labels: Mapping[str, str] | None = None
36
37
  ) -> None:
37
38
  """Bind event listeners for pool metrics. Works for sync Engine.
38
39
  For AsyncEngine pass engine.sync_engine."""
@@ -42,31 +43,31 @@ def bind_sqlalchemy_pool_metrics(
42
43
  from sqlalchemy import event
43
44
 
44
45
  @event.listens_for(sync_engine, "engine_connect")
45
- def _(conn, branch): # noqa
46
+ def _(conn, branch):
46
47
  # Update gauges on engine_connect as a cheap heartbeat
47
48
  pool = sync_engine.pool
48
49
  try:
49
- _pool_in_use.labels(label).set(pool.checkedout())
50
- _pool_available.labels(label).set(pool.size() - pool.checkedout())
50
+ _pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
51
+ _pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
51
52
  except Exception:
52
53
  pass
53
54
 
54
55
  @event.listens_for(sync_engine, "checkout")
55
- def _checkout(dbapi_con, con_record, con_proxy): # noqa
56
+ def _checkout(dbapi_con, con_record, con_proxy):
56
57
  _pool_checked_out_total.labels(label).inc()
57
58
  try:
58
59
  pool = sync_engine.pool
59
- _pool_in_use.labels(label).set(pool.checkedout())
60
- _pool_available.labels(label).set(pool.size() - pool.checkedout())
60
+ _pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
61
+ _pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
61
62
  except Exception:
62
63
  pass
63
64
 
64
65
  @event.listens_for(sync_engine, "checkin")
65
- def _checkin(dbapi_con, con_record): # noqa
66
+ def _checkin(dbapi_con, con_record):
66
67
  _pool_checked_in_total.labels(label).inc()
67
68
  try:
68
69
  pool = sync_engine.pool
69
- _pool_in_use.labels(label).set(pool.checkedout())
70
- _pool_available.labels(label).set(pool.size() - pool.checkedout())
70
+ _pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
71
+ _pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
71
72
  except Exception:
72
73
  pass
svc_infra/obs/metrics.py CHANGED
@@ -1,12 +1,13 @@
1
- from __future__ import annotations
1
+ """Lightweight metrics hooks for abuse heuristics.
2
2
 
3
+ Intentionally minimal to avoid pulling full metrics stacks; these are no-ops by
4
+ default but can be swapped in tests or wired to a metrics backend by overriding the
5
+ functions.
3
6
  """
4
- Lightweight metrics hooks for abuse heuristics. Intentionally minimal to avoid pulling
5
- full metrics stacks; these are no-ops by default but can be swapped in tests or wired
6
- to a metrics backend by overriding the functions.
7
- """
8
7
 
9
- from typing import Callable, Optional
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
10
11
 
11
12
  # Function variables so applications/tests can replace them at runtime.
12
13
  on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
@@ -18,7 +19,7 @@ Args:
18
19
  retry_after: seconds until next allowed attempt
19
20
  """
20
21
 
21
- on_suspect_payload: Callable[[Optional[str], int], None] | None = None
22
+ on_suspect_payload: Callable[[str | None, int], None] | None = None
22
23
  """
23
24
  Called when a request exceeds the configured size limit.
24
25
  Args:
@@ -36,7 +37,7 @@ def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
36
37
  pass
37
38
 
38
39
 
39
- def emit_suspect_payload(path: Optional[str], size: int) -> None:
40
+ def emit_suspect_payload(path: str | None, size: int) -> None:
40
41
  if on_suspect_payload:
41
42
  try:
42
43
  on_suspect_payload(path, size)