svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +871 -0
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +19 -10
- svc_infra/apf_payments/service.py +211 -68
- svc_infra/apf_payments/settings.py +27 -3
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +145 -46
- svc_infra/api/fastapi/apf_payments/setup.py +26 -8
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +27 -14
- svc_infra/api/fastapi/auth/gaurd.py +104 -13
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
- svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +29 -5
- svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
- svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -56
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +52 -0
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +53 -0
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +212 -0
- svc_infra/security/audit_service.py +74 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +101 -0
- svc_infra/security/jwt_rotation.py +105 -0
- svc_infra/security/lockout.py +102 -0
- svc_infra/security/models.py +287 -0
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +130 -0
- svc_infra/security/passwords.py +79 -0
- svc_infra/security/permissions.py +171 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +100 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.589.dist-info/METADATA +0 -79
- svc_infra-0.1.589.dist-info/RECORD +0 -234
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/mcp/svc_infra_mcp.py
CHANGED
|
@@ -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
|
|
23
|
-
sql_revision = "sql
|
|
24
|
-
sql_upgrade = "sql
|
|
25
|
-
sql_downgrade = "sql
|
|
26
|
-
sql_current = "sql
|
|
27
|
-
sql_history = "sql
|
|
28
|
-
sql_stamp = "sql
|
|
29
|
-
sql_merge_heads = "sql
|
|
30
|
-
sql_setup_and_migrate = "sql
|
|
31
|
-
sql_scaffold = "sql
|
|
32
|
-
sql_scaffold_models = "sql
|
|
33
|
-
sql_scaffold_schemas = "sql
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Callable, Iterable, Optional
|
|
3
|
+
from typing import Any, Callable, Iterable, Optional, Protocol
|
|
4
4
|
|
|
5
5
|
from svc_infra.obs.settings import ObservabilitySettings
|
|
6
6
|
|
|
@@ -9,12 +9,20 @@ def _want_metrics(cfg: ObservabilitySettings) -> bool:
|
|
|
9
9
|
return bool(cfg.METRICS_ENABLED)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class RouteClassifier(Protocol):
|
|
13
|
+
def __call__(
|
|
14
|
+
self, route_path: str, method: str
|
|
15
|
+
) -> str: # e.g., returns "public|internal|admin"
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
12
19
|
def add_observability(
|
|
13
20
|
app: Any | None = None,
|
|
14
21
|
*,
|
|
15
22
|
db_engines: Optional[Iterable[Any]] = None,
|
|
16
23
|
metrics_path: str | None = None,
|
|
17
24
|
skip_metric_paths: Optional[Iterable[str]] = None,
|
|
25
|
+
route_classifier: RouteClassifier | None = None,
|
|
18
26
|
) -> Callable[[], None]:
|
|
19
27
|
"""
|
|
20
28
|
Enable Prometheus metrics for the ASGI app and optional SQLAlchemy pool metrics.
|
|
@@ -25,21 +33,66 @@ def add_observability(
|
|
|
25
33
|
# --- Metrics (Prometheus) — import lazily so CLIs/tests don’t require prometheus_client
|
|
26
34
|
if app is not None and _want_metrics(cfg):
|
|
27
35
|
try:
|
|
28
|
-
from svc_infra.obs.metrics.asgi import
|
|
36
|
+
from svc_infra.obs.metrics.asgi import ( # lazy
|
|
37
|
+
PrometheusMiddleware,
|
|
38
|
+
add_prometheus,
|
|
39
|
+
metrics_endpoint,
|
|
40
|
+
)
|
|
29
41
|
|
|
30
42
|
path = metrics_path or cfg.METRICS_PATH
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
skip_paths = tuple(skip_metric_paths or (path, "/health", "/healthz"))
|
|
44
|
+
# If a route_classifier is provided, use a custom route_resolver to append class label
|
|
45
|
+
if route_classifier is None:
|
|
46
|
+
add_prometheus(
|
|
47
|
+
app,
|
|
48
|
+
path=path,
|
|
49
|
+
skip_paths=skip_paths,
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Install middleware manually to pass route_resolver
|
|
53
|
+
def _resolver(req):
|
|
54
|
+
# Base template
|
|
55
|
+
from svc_infra.obs.metrics.asgi import _route_template
|
|
56
|
+
|
|
57
|
+
base = _route_template(req)
|
|
58
|
+
method = getattr(req, "method", "GET")
|
|
59
|
+
cls = route_classifier(base, method)
|
|
60
|
+
# Encode as base|class for downstream label splitting in dashboards
|
|
61
|
+
return f"{base}|{cls}"
|
|
62
|
+
|
|
63
|
+
app.add_middleware(
|
|
64
|
+
PrometheusMiddleware,
|
|
65
|
+
skip_paths=skip_paths,
|
|
66
|
+
route_resolver=_resolver,
|
|
67
|
+
)
|
|
68
|
+
# Mount /metrics endpoint without re-adding middleware
|
|
69
|
+
try:
|
|
70
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
71
|
+
from svc_infra.app.env import (
|
|
72
|
+
CURRENT_ENVIRONMENT,
|
|
73
|
+
DEV_ENV,
|
|
74
|
+
LOCAL_ENV,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
router = public_router()
|
|
78
|
+
router.add_api_route(
|
|
79
|
+
path,
|
|
80
|
+
endpoint=metrics_endpoint(),
|
|
81
|
+
include_in_schema=CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV),
|
|
82
|
+
tags=["observability"],
|
|
83
|
+
)
|
|
84
|
+
app.include_router(router)
|
|
85
|
+
except Exception:
|
|
86
|
+
app.add_route(path, metrics_endpoint())
|
|
36
87
|
except Exception:
|
|
37
88
|
pass
|
|
38
89
|
|
|
39
90
|
# --- DB pool metrics (best effort) — also lazy
|
|
40
91
|
if db_engines:
|
|
41
92
|
try:
|
|
42
|
-
from svc_infra.obs.metrics.sqlalchemy import
|
|
93
|
+
from svc_infra.obs.metrics.sqlalchemy import (
|
|
94
|
+
bind_sqlalchemy_pool_metrics,
|
|
95
|
+
) # lazy
|
|
43
96
|
|
|
44
97
|
for eng in db_engines:
|
|
45
98
|
try:
|
|
@@ -51,7 +104,10 @@ def add_observability(
|
|
|
51
104
|
|
|
52
105
|
# --- HTTP client metrics (best effort) — import lazily
|
|
53
106
|
try:
|
|
54
|
-
from svc_infra.obs.metrics.http import
|
|
107
|
+
from svc_infra.obs.metrics.http import (
|
|
108
|
+
instrument_httpx,
|
|
109
|
+
instrument_requests,
|
|
110
|
+
) # lazy
|
|
55
111
|
|
|
56
112
|
try:
|
|
57
113
|
instrument_requests()
|
svc_infra/obs/cloud_dash.py
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Metrics package public API.
|
|
2
|
+
|
|
3
|
+
Provides lightweight, overridable hooks for abuse heuristics so callers can
|
|
4
|
+
plug in logging or a metrics backend without a hard dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
# Function variables so applications/tests can replace them at runtime.
|
|
12
|
+
on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
|
|
13
|
+
"""
|
|
14
|
+
Called when a request is rate-limited.
|
|
15
|
+
Args:
|
|
16
|
+
key: identifier used for rate limiting (e.g., API key or IP)
|
|
17
|
+
limit: configured limit for the window
|
|
18
|
+
retry_after: seconds until next allowed attempt
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
on_suspect_payload: Callable[[Optional[str], int], None] | None = None
|
|
22
|
+
"""
|
|
23
|
+
Called when a request exceeds the configured size limit.
|
|
24
|
+
Args:
|
|
25
|
+
path: request path if available
|
|
26
|
+
size: reported content-length
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
|
|
31
|
+
if on_rate_limit_exceeded:
|
|
32
|
+
try:
|
|
33
|
+
on_rate_limit_exceeded(key, limit, retry_after)
|
|
34
|
+
except Exception:
|
|
35
|
+
# Never break request flow on metrics exceptions
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit_suspect_payload(path: Optional[str], size: int) -> None:
|
|
40
|
+
if on_suspect_payload:
|
|
41
|
+
try:
|
|
42
|
+
on_suspect_payload(path, size)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"emit_rate_limited",
|
|
49
|
+
"emit_suspect_payload",
|
|
50
|
+
"on_rate_limit_exceeded",
|
|
51
|
+
"on_suspect_payload",
|
|
52
|
+
]
|
svc_infra/obs/metrics/asgi.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
|
-
from typing import Any, Callable, Iterable, Optional
|
|
5
|
+
from typing import Any, Callable, Iterable, Optional, cast
|
|
6
6
|
|
|
7
7
|
from starlette.requests import Request
|
|
8
8
|
from starlette.responses import PlainTextResponse, Response
|
|
@@ -34,7 +34,7 @@ def _register_default_collectors_once() -> None:
|
|
|
34
34
|
try:
|
|
35
35
|
# These imports are no-ops if already registered by the client,
|
|
36
36
|
# but GCCollector typically needs explicit instantiation.
|
|
37
|
-
from prometheus_client import GCCollector
|
|
37
|
+
from prometheus_client import GCCollector
|
|
38
38
|
|
|
39
39
|
GCCollector() # registers GC metrics
|
|
40
40
|
_default_collectors_ready = True
|
|
@@ -97,9 +97,9 @@ def _init_metrics() -> None:
|
|
|
97
97
|
def _route_template(req: Request) -> str:
|
|
98
98
|
route = getattr(req, "scope", {}).get("route")
|
|
99
99
|
if route and hasattr(route, "path_format"):
|
|
100
|
-
return route.path_format
|
|
100
|
+
return cast(str, route.path_format)
|
|
101
101
|
if route and hasattr(route, "path"):
|
|
102
|
-
return route.path
|
|
102
|
+
return cast(str, route.path)
|
|
103
103
|
return req.url.path or "/*unmatched*"
|
|
104
104
|
|
|
105
105
|
|
|
@@ -186,9 +186,13 @@ class PrometheusMiddleware:
|
|
|
186
186
|
if _http_requests_total:
|
|
187
187
|
_http_requests_total.labels(method, route_for_stats, code).inc()
|
|
188
188
|
if _http_request_duration:
|
|
189
|
-
_http_request_duration.labels(route_for_stats, method).observe(
|
|
189
|
+
_http_request_duration.labels(route_for_stats, method).observe(
|
|
190
|
+
elapsed
|
|
191
|
+
)
|
|
190
192
|
if _http_response_size:
|
|
191
|
-
_http_response_size.labels(route_for_stats, method).observe(
|
|
193
|
+
_http_response_size.labels(route_for_stats, method).observe(
|
|
194
|
+
bytes_sent
|
|
195
|
+
)
|
|
192
196
|
except Exception:
|
|
193
197
|
pass
|
|
194
198
|
try:
|
|
@@ -237,7 +241,9 @@ def metrics_endpoint():
|
|
|
237
241
|
return handler
|
|
238
242
|
|
|
239
243
|
|
|
240
|
-
def add_prometheus(
|
|
244
|
+
def add_prometheus(
|
|
245
|
+
app, *, path: str = "/metrics", skip_paths: Optional[Iterable[str]] = None
|
|
246
|
+
):
|
|
241
247
|
"""Convenience for FastAPI/Starlette apps."""
|
|
242
248
|
# Add middleware
|
|
243
249
|
app.add_middleware(
|
svc_infra/obs/metrics/http.py
CHANGED
|
@@ -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
|
|
54
|
+
setattr(requests.sessions.Session, "request", _wrapped)
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def instrument_httpx():
|
|
@@ -74,7 +74,9 @@ def instrument_httpx():
|
|
|
74
74
|
raise
|
|
75
75
|
finally:
|
|
76
76
|
_http_client_total.labels(host, method, code).inc()
|
|
77
|
-
_http_client_duration.labels(host, method).observe(
|
|
77
|
+
_http_client_duration.labels(host, method).observe(
|
|
78
|
+
time.perf_counter() - start
|
|
79
|
+
)
|
|
78
80
|
|
|
79
81
|
return _wrapped
|
|
80
82
|
|
|
@@ -91,7 +93,9 @@ def instrument_httpx():
|
|
|
91
93
|
raise
|
|
92
94
|
finally:
|
|
93
95
|
_http_client_total.labels(host, method, code).inc()
|
|
94
|
-
_http_client_duration.labels(host, method).observe(
|
|
96
|
+
_http_client_duration.labels(host, method).observe(
|
|
97
|
+
time.perf_counter() - start
|
|
98
|
+
)
|
|
95
99
|
|
|
96
|
-
httpx.Client
|
|
97
|
-
httpx.AsyncClient
|
|
100
|
+
setattr(httpx.Client, "send", _wrap_sync_send(_orig_sync))
|
|
101
|
+
setattr(httpx.AsyncClient, "send", _wrapped_async)
|
|
@@ -7,7 +7,7 @@ from sqlalchemy.engine import Engine
|
|
|
7
7
|
try:
|
|
8
8
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
9
9
|
except Exception: # optional
|
|
10
|
-
AsyncEngine = None # type: ignore
|
|
10
|
+
AsyncEngine = None # type: ignore[misc,assignment]
|
|
11
11
|
|
|
12
12
|
from .base import counter, gauge
|
|
13
13
|
|
|
@@ -23,8 +23,12 @@ _pool_available = gauge(
|
|
|
23
23
|
labels=["db"],
|
|
24
24
|
multiprocess_mode="livesum",
|
|
25
25
|
)
|
|
26
|
-
_pool_checked_out_total = counter(
|
|
27
|
-
|
|
26
|
+
_pool_checked_out_total = counter(
|
|
27
|
+
"db_pool_checkedout_total", "Total checkouts", labels=["db"]
|
|
28
|
+
)
|
|
29
|
+
_pool_checked_in_total = counter(
|
|
30
|
+
"db_pool_checkedin_total", "Total checkins", labels=["db"]
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
def _label(labels: Optional[Mapping[str, str]]) -> str:
|
|
@@ -46,8 +50,8 @@ def bind_sqlalchemy_pool_metrics(
|
|
|
46
50
|
# Update gauges on engine_connect as a cheap heartbeat
|
|
47
51
|
pool = sync_engine.pool
|
|
48
52
|
try:
|
|
49
|
-
_pool_in_use.labels(label).set(pool.checkedout())
|
|
50
|
-
_pool_available.labels(label).set(pool.size() - pool.checkedout())
|
|
53
|
+
_pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
|
|
54
|
+
_pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
|
|
51
55
|
except Exception:
|
|
52
56
|
pass
|
|
53
57
|
|
|
@@ -56,8 +60,8 @@ def bind_sqlalchemy_pool_metrics(
|
|
|
56
60
|
_pool_checked_out_total.labels(label).inc()
|
|
57
61
|
try:
|
|
58
62
|
pool = sync_engine.pool
|
|
59
|
-
_pool_in_use.labels(label).set(pool.checkedout())
|
|
60
|
-
_pool_available.labels(label).set(pool.size() - pool.checkedout())
|
|
63
|
+
_pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
|
|
64
|
+
_pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
|
|
61
65
|
except Exception:
|
|
62
66
|
pass
|
|
63
67
|
|
|
@@ -66,7 +70,7 @@ def bind_sqlalchemy_pool_metrics(
|
|
|
66
70
|
_pool_checked_in_total.labels(label).inc()
|
|
67
71
|
try:
|
|
68
72
|
pool = sync_engine.pool
|
|
69
|
-
_pool_in_use.labels(label).set(pool.checkedout())
|
|
70
|
-
_pool_available.labels(label).set(pool.size() - pool.checkedout())
|
|
73
|
+
_pool_in_use.labels(label).set(pool.checkedout()) # type: ignore[attr-defined]
|
|
74
|
+
_pool_available.labels(label).set(pool.size() - pool.checkedout()) # type: ignore[attr-defined]
|
|
71
75
|
except Exception:
|
|
72
76
|
pass
|
svc_infra/obs/metrics.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Lightweight metrics hooks for abuse heuristics.
|
|
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.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Callable, Optional
|
|
11
|
+
|
|
12
|
+
# Function variables so applications/tests can replace them at runtime.
|
|
13
|
+
on_rate_limit_exceeded: Callable[[str, int, int], None] | None = None
|
|
14
|
+
"""
|
|
15
|
+
Called when a request is rate-limited.
|
|
16
|
+
Args:
|
|
17
|
+
key: identifier used for rate limiting (e.g., API key or IP)
|
|
18
|
+
limit: configured limit for the window
|
|
19
|
+
retry_after: seconds until next allowed attempt
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
on_suspect_payload: Callable[[Optional[str], int], None] | None = None
|
|
23
|
+
"""
|
|
24
|
+
Called when a request exceeds the configured size limit.
|
|
25
|
+
Args:
|
|
26
|
+
path: request path if available
|
|
27
|
+
size: reported content-length
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def emit_rate_limited(key: str, limit: int, retry_after: int) -> None:
|
|
32
|
+
if on_rate_limit_exceeded:
|
|
33
|
+
try:
|
|
34
|
+
on_rate_limit_exceeded(key, limit, retry_after)
|
|
35
|
+
except Exception:
|
|
36
|
+
# Never break request flow on metrics exceptions
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def emit_suspect_payload(path: Optional[str], size: int) -> None:
|
|
41
|
+
if on_suspect_payload:
|
|
42
|
+
try:
|
|
43
|
+
on_suspect_payload(path, size)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"emit_rate_limited",
|
|
50
|
+
"emit_suspect_payload",
|
|
51
|
+
"on_rate_limit_exceeded",
|
|
52
|
+
"on_suspect_payload",
|
|
53
|
+
]
|
svc_infra/obs/settings.py
CHANGED
|
@@ -14,8 +14,12 @@ class ObservabilitySettings(BaseSettings):
|
|
|
14
14
|
- METRICS_DEFAULT_BUCKETS=comma-separated seconds (optional)
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
METRICS_ENABLED: bool = Field(
|
|
18
|
-
|
|
17
|
+
METRICS_ENABLED: bool = Field(
|
|
18
|
+
default=True, description="Enable Prometheus metrics exposure"
|
|
19
|
+
)
|
|
20
|
+
METRICS_PATH: str = Field(
|
|
21
|
+
default="/metrics", description="HTTP path for metrics endpoint"
|
|
22
|
+
)
|
|
19
23
|
METRICS_DEFAULT_BUCKETS: tuple[float, ...] = Field(
|
|
20
24
|
default=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0),
|
|
21
25
|
description="Default histogram buckets (seconds)",
|