cuneus 0.2.8__py3-none-any.whl → 0.2.10__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.
- cuneus/cli.py +13 -105
- cuneus/core/application.py +11 -15
- cuneus/core/exceptions.py +2 -6
- cuneus/core/extensions.py +7 -0
- cuneus/core/logging.py +1 -1
- cuneus/core/settings.py +20 -4
- cuneus/dependencies.py +79 -0
- cuneus/ext/database.py +278 -0
- cuneus/ext/health.py +78 -81
- cuneus/ext/otel.py +279 -0
- cuneus/ext/server.py +54 -0
- cuneus/utils.py +12 -0
- {cuneus-0.2.8.dist-info → cuneus-0.2.10.dist-info}/METADATA +5 -1
- cuneus-0.2.10.dist-info/RECORD +20 -0
- cuneus-0.2.8.dist-info/RECORD +0 -15
- {cuneus-0.2.8.dist-info → cuneus-0.2.10.dist-info}/WHEEL +0 -0
- {cuneus-0.2.8.dist-info → cuneus-0.2.10.dist-info}/entry_points.txt +0 -0
cuneus/cli.py
CHANGED
|
@@ -1,118 +1,30 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Cuneus CLI entry point."""
|
|
2
2
|
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import importlib
|
|
6
|
-
import sys
|
|
7
3
|
from typing import Any, cast
|
|
8
4
|
|
|
9
5
|
import click
|
|
10
6
|
|
|
11
|
-
from .core.settings import Settings
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def import_from_string(import_str: str) -> Any:
|
|
15
|
-
"""Import an object from a module:attribute string."""
|
|
16
|
-
# Ensure cwd is in path for local imports
|
|
17
|
-
cwd = str(Path.cwd())
|
|
18
|
-
if cwd not in sys.path:
|
|
19
|
-
sys.path.insert(0, cwd)
|
|
20
|
-
|
|
21
|
-
module_path, _, attr = import_str.partition(":")
|
|
22
|
-
if not attr:
|
|
23
|
-
raise ValueError(
|
|
24
|
-
f"module_path missing function {import_str} expecting 'module.path:name'"
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
module = importlib.import_module(module_path)
|
|
28
|
-
return getattr(module, attr)
|
|
29
|
-
|
|
7
|
+
from .core.settings import Settings, ensure_project_in_path
|
|
8
|
+
from .utils import import_from_string
|
|
30
9
|
|
|
31
|
-
def get_user_cli() -> click.Group | None:
|
|
32
|
-
"""Attempt to load user's CLI from config."""
|
|
33
|
-
config = Settings()
|
|
34
10
|
|
|
11
|
+
def get_user_cli(config: Settings = Settings()) -> click.Group | None:
|
|
12
|
+
"""Load CLI from config."""
|
|
35
13
|
try:
|
|
36
14
|
return cast(click.Group, import_from_string(config.cli_module))
|
|
37
15
|
except (ImportError, AttributeError) as e:
|
|
38
|
-
click.echo(
|
|
39
|
-
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@click.group()
|
|
46
|
-
@click.pass_context
|
|
47
|
-
def cli(ctx: click.Context) -> None: # pragma: no cover
|
|
48
|
-
"""Cuneus CLI - FastAPI application framework."""
|
|
49
|
-
ctx.ensure_object(dict)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@cli.command()
|
|
53
|
-
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
54
|
-
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
55
|
-
def dev(host: str, port: int) -> None:
|
|
56
|
-
"""Run the application server."""
|
|
57
|
-
import uvicorn
|
|
58
|
-
|
|
59
|
-
config = Settings()
|
|
60
|
-
|
|
61
|
-
uvicorn.run(
|
|
62
|
-
config.app_module,
|
|
63
|
-
host=host,
|
|
64
|
-
port=port,
|
|
65
|
-
reload=True,
|
|
66
|
-
log_config=None,
|
|
67
|
-
server_header=False,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@cli.command()
|
|
72
|
-
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
73
|
-
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
74
|
-
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
75
|
-
def prod(host: str, port: int, workers: int) -> None:
|
|
76
|
-
"""Run the application server."""
|
|
77
|
-
import uvicorn
|
|
78
|
-
|
|
79
|
-
config = Settings()
|
|
80
|
-
|
|
81
|
-
uvicorn.run(
|
|
82
|
-
config.app_module,
|
|
83
|
-
host=host,
|
|
84
|
-
port=port,
|
|
85
|
-
workers=workers,
|
|
86
|
-
log_config=None,
|
|
87
|
-
server_header=False,
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@cli.command()
|
|
92
|
-
def routes() -> None:
|
|
93
|
-
"""List all registered routes."""
|
|
94
|
-
config = Settings()
|
|
95
|
-
app = import_from_string(config.app_module)
|
|
96
|
-
|
|
97
|
-
for route in app.routes:
|
|
98
|
-
if hasattr(route, "methods"): # pragma: no branch
|
|
99
|
-
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
100
|
-
click.echo(f"{methods:8} {route.path}")
|
|
16
|
+
click.echo(f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True)
|
|
17
|
+
return None
|
|
101
18
|
|
|
102
19
|
|
|
103
20
|
class CuneusCLI(click.Group):
|
|
104
|
-
"""
|
|
21
|
+
"""Delegates to the app's CLI from config."""
|
|
105
22
|
|
|
106
23
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
107
24
|
super().__init__(*args, **kwargs)
|
|
108
25
|
self._user_cli: click.Group | None = None
|
|
109
26
|
self._user_cli_loaded = False
|
|
110
27
|
|
|
111
|
-
# Register base commands directly
|
|
112
|
-
self.add_command(dev)
|
|
113
|
-
self.add_command(prod)
|
|
114
|
-
self.add_command(routes)
|
|
115
|
-
|
|
116
28
|
@property
|
|
117
29
|
def user_cli(self) -> click.Group | None:
|
|
118
30
|
if not self._user_cli_loaded:
|
|
@@ -121,21 +33,17 @@ class CuneusCLI(click.Group):
|
|
|
121
33
|
return self._user_cli
|
|
122
34
|
|
|
123
35
|
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
124
|
-
commands = set(super().list_commands(ctx))
|
|
125
36
|
if self.user_cli:
|
|
126
|
-
|
|
127
|
-
return
|
|
37
|
+
return self.user_cli.list_commands(ctx)
|
|
38
|
+
return []
|
|
128
39
|
|
|
129
40
|
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
130
|
-
# User CLI takes priority
|
|
131
41
|
if self.user_cli:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return cmd
|
|
135
|
-
return super().get_command(ctx, cmd_name)
|
|
42
|
+
return self.user_cli.get_command(ctx, cmd_name)
|
|
43
|
+
return None
|
|
136
44
|
|
|
137
45
|
|
|
138
|
-
|
|
46
|
+
ensure_project_in_path()
|
|
139
47
|
main = CuneusCLI(help="Cuneus CLI - FastAPI application framework")
|
|
140
48
|
|
|
141
49
|
|
cuneus/core/application.py
CHANGED
|
@@ -19,8 +19,9 @@ from starlette.middleware import Middleware
|
|
|
19
19
|
from .settings import Settings
|
|
20
20
|
from .exceptions import ExceptionExtension
|
|
21
21
|
from .logging import LoggingExtension
|
|
22
|
-
from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware
|
|
22
|
+
from .extensions import Extension, HasCLI, HasExceptionHandler, HasMiddleware, HasRoutes
|
|
23
23
|
from ..ext.health import HealthExtension
|
|
24
|
+
from ..ext.server import ServerExtension
|
|
24
25
|
|
|
25
26
|
logger = structlog.stdlib.get_logger("cuneus")
|
|
26
27
|
|
|
@@ -30,6 +31,7 @@ DEFAULTS = (
|
|
|
30
31
|
LoggingExtension,
|
|
31
32
|
HealthExtension,
|
|
32
33
|
ExceptionExtension,
|
|
34
|
+
ServerExtension,
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
|
|
@@ -39,9 +41,7 @@ class ExtensionConflictError(Exception):
|
|
|
39
41
|
pass
|
|
40
42
|
|
|
41
43
|
|
|
42
|
-
def _instantiate_extension(
|
|
43
|
-
ext: ExtensionInput, settings: Settings | None = None
|
|
44
|
-
) -> Extension:
|
|
44
|
+
def _instantiate_extension(ext: ExtensionInput, settings: Settings | None = None) -> Extension:
|
|
45
45
|
if isinstance(ext, type) or callable(ext):
|
|
46
46
|
try:
|
|
47
47
|
return ext(settings=settings)
|
|
@@ -56,7 +56,7 @@ def build_app(
|
|
|
56
56
|
settings: Settings | None = None,
|
|
57
57
|
include_defaults: bool = True,
|
|
58
58
|
**fastapi_kwargs: Any,
|
|
59
|
-
) -> tuple[FastAPI, click.Group]:
|
|
59
|
+
) -> tuple[FastAPI, click.Group, svcs.fastapi.lifespan]:
|
|
60
60
|
"""
|
|
61
61
|
Build a FastAPI with extensions preconfigured.
|
|
62
62
|
|
|
@@ -93,17 +93,9 @@ def build_app(
|
|
|
93
93
|
|
|
94
94
|
all_extensions = [_instantiate_extension(ext, settings) for ext in all_inputs]
|
|
95
95
|
|
|
96
|
-
@click.group()
|
|
97
|
-
@click.pass_context
|
|
98
|
-
def app_cli(ctx: click.Context) -> None:
|
|
99
|
-
"""Application CLI."""
|
|
100
|
-
ctx.ensure_object(dict)
|
|
101
|
-
|
|
102
96
|
@svcs.fastapi.lifespan
|
|
103
97
|
@asynccontextmanager
|
|
104
|
-
async def lifespan(
|
|
105
|
-
app: FastAPI, registry: svcs.Registry
|
|
106
|
-
) -> AsyncIterator[dict[str, Any]]:
|
|
98
|
+
async def lifespan(app: FastAPI, registry: svcs.Registry) -> AsyncIterator[dict[str, Any]]:
|
|
107
99
|
async with AsyncExitStack() as stack:
|
|
108
100
|
state: dict[str, Any] = {}
|
|
109
101
|
|
|
@@ -121,6 +113,7 @@ def build_app(
|
|
|
121
113
|
|
|
122
114
|
# Parse extensions for middleware and cli commands
|
|
123
115
|
middleware: list[Middleware] = []
|
|
116
|
+
app_cli = click.Group()
|
|
124
117
|
|
|
125
118
|
for ext in all_extensions:
|
|
126
119
|
ext_name = ext.__class__.__name__
|
|
@@ -139,5 +132,8 @@ def build_app(
|
|
|
139
132
|
if isinstance(ext, HasExceptionHandler):
|
|
140
133
|
logger.debug(f"Loading exception handlers from {ext_name}")
|
|
141
134
|
ext.add_exception_handler(app)
|
|
135
|
+
if isinstance(ext, HasRoutes):
|
|
136
|
+
logger.debug(f"Loading routes from {ext_name}")
|
|
137
|
+
ext.add_routes(app)
|
|
142
138
|
|
|
143
|
-
return app, app_cli
|
|
139
|
+
return app, app_cli, lifespan
|
cuneus/core/exceptions.py
CHANGED
|
@@ -160,9 +160,7 @@ class ExceptionExtension(BaseExtension):
|
|
|
160
160
|
app.add_exception_handler(AppException, self._handle_app_exception) # type: ignore[arg-type]
|
|
161
161
|
app.add_exception_handler(Exception, self._handle_unexpected_exception)
|
|
162
162
|
|
|
163
|
-
def _handle_app_exception(
|
|
164
|
-
self, request: Request, exc: AppException
|
|
165
|
-
) -> JSONResponse:
|
|
163
|
+
def _handle_app_exception(self, request: Request, exc: AppException) -> JSONResponse:
|
|
166
164
|
if exc.status_code >= 500 and self.settings.log_server_errors:
|
|
167
165
|
log.exception("server_error", error_code=exc.error_code)
|
|
168
166
|
else:
|
|
@@ -180,9 +178,7 @@ class ExceptionExtension(BaseExtension):
|
|
|
180
178
|
headers=headers,
|
|
181
179
|
)
|
|
182
180
|
|
|
183
|
-
def _handle_unexpected_exception(
|
|
184
|
-
self, request: Request, exc: Exception
|
|
185
|
-
) -> JSONResponse:
|
|
181
|
+
def _handle_unexpected_exception(self, request: Request, exc: Exception) -> JSONResponse:
|
|
186
182
|
log.exception("unexpected_error", exc_info=exc)
|
|
187
183
|
response: dict[str, Any] = {
|
|
188
184
|
"error": {
|
cuneus/core/extensions.py
CHANGED
|
@@ -67,6 +67,13 @@ class HasExceptionHandler(Protocol):
|
|
|
67
67
|
def add_exception_handler(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class HasRoutes(Protocol):
|
|
72
|
+
"""Extension that provides routes."""
|
|
73
|
+
|
|
74
|
+
def add_routes(self, app: FastAPI) -> None: ... # pragma: no cover
|
|
75
|
+
|
|
76
|
+
|
|
70
77
|
class BaseExtension:
|
|
71
78
|
"""
|
|
72
79
|
Base class for extensions with explicit startup/shutdown hooks.
|
cuneus/core/logging.py
CHANGED
|
@@ -43,7 +43,7 @@ def configure_structlog(settings: Settings | None = None) -> None:
|
|
|
43
43
|
term_width = shutil.get_terminal_size().columns
|
|
44
44
|
pad_event = term_width - 36
|
|
45
45
|
renderer: structlog.types.Processor = structlog.dev.ConsoleRenderer(
|
|
46
|
-
colors=True,
|
|
46
|
+
colors=True, pad_event_to=pad_event
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
# Configure structlog
|
cuneus/core/settings.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
4
5
|
from pydantic_settings import (
|
|
5
6
|
BaseSettings,
|
|
6
7
|
PydanticBaseSettingsSource,
|
|
@@ -8,8 +9,6 @@ from pydantic_settings import (
|
|
|
8
9
|
SettingsConfigDict,
|
|
9
10
|
)
|
|
10
11
|
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
12
|
DEFAULT_TOOL_NAME = "cuneus"
|
|
14
13
|
|
|
15
14
|
|
|
@@ -40,7 +39,6 @@ class CuneusBaseSettings(BaseSettings):
|
|
|
40
39
|
|
|
41
40
|
|
|
42
41
|
class Settings(CuneusBaseSettings):
|
|
43
|
-
|
|
44
42
|
model_config = SettingsConfigDict(
|
|
45
43
|
env_file=".env",
|
|
46
44
|
env_file_encoding="utf-8",
|
|
@@ -64,3 +62,21 @@ class Settings(CuneusBaseSettings):
|
|
|
64
62
|
# health
|
|
65
63
|
health_enabled: bool = True
|
|
66
64
|
health_prefix: str = "/healthz"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def get_project_root(cls) -> pathlib.Path:
|
|
68
|
+
"""
|
|
69
|
+
Get the project root by inspecting where pydantic-settings
|
|
70
|
+
found the pyproject.toml file.
|
|
71
|
+
"""
|
|
72
|
+
source = PyprojectTomlConfigSettingsSource(
|
|
73
|
+
cls,
|
|
74
|
+
)
|
|
75
|
+
return source.toml_file_path.parent
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ensure_project_in_path() -> None:
|
|
79
|
+
"""Add project root to sys.path if not already present."""
|
|
80
|
+
project_root = str(Settings.get_project_root())
|
|
81
|
+
if project_root not in sys.path: # pragma: no branch
|
|
82
|
+
sys.path.insert(0, project_root)
|
cuneus/dependencies.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# cuneus/core/dependencies.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Dependency:
|
|
13
|
+
"""A required dependency with install hint."""
|
|
14
|
+
|
|
15
|
+
import_name: str
|
|
16
|
+
package_name: str | None = None # pip package name if different from import
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def pip_name(self) -> str:
|
|
20
|
+
return self.package_name or self.import_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingDependencyError(ImportError):
|
|
24
|
+
"""Raised when required dependencies are not installed."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, extension: str, missing: list[Dependency]):
|
|
27
|
+
self.extension = extension
|
|
28
|
+
self.missing = missing
|
|
29
|
+
packages = " ".join(d.pip_name for d in missing)
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"{extension} requires additional dependencies. Install with: uv add {packages}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_dependencies(extension: str, *deps: Dependency) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Check that dependencies are installed, raise helpful error if not.
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
from cuneus.core.dependencies import check_dependencies, Dependency
|
|
41
|
+
|
|
42
|
+
check_dependencies(
|
|
43
|
+
"DatabaseExtension",
|
|
44
|
+
Dependency("sqlalchemy"),
|
|
45
|
+
Dependency("asyncpg"),
|
|
46
|
+
)
|
|
47
|
+
"""
|
|
48
|
+
missing = []
|
|
49
|
+
for dep in deps:
|
|
50
|
+
try:
|
|
51
|
+
importlib.import_module(dep.import_name)
|
|
52
|
+
except ImportError:
|
|
53
|
+
missing.append(dep)
|
|
54
|
+
|
|
55
|
+
if missing:
|
|
56
|
+
raise MissingDependencyError(extension, missing)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def warn_missing(extension: str, *deps: Dependency) -> list[Dependency]:
|
|
60
|
+
"""
|
|
61
|
+
Check dependencies but only warn, don't raise. Returns list of missing.
|
|
62
|
+
|
|
63
|
+
Useful for optional features within an extension.
|
|
64
|
+
"""
|
|
65
|
+
missing = []
|
|
66
|
+
for dep in deps:
|
|
67
|
+
try:
|
|
68
|
+
importlib.import_module(dep.import_name)
|
|
69
|
+
except ImportError:
|
|
70
|
+
missing.append(dep)
|
|
71
|
+
|
|
72
|
+
if missing:
|
|
73
|
+
packages = " ".join(d.pip_name for d in missing)
|
|
74
|
+
logger.warning(
|
|
75
|
+
f"{extension}: optional dependencies not installed. "
|
|
76
|
+
f"Some features disabled. Install with: uv add {packages}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return missing
|
cuneus/ext/database.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# cuneus/ext/database.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, AsyncIterator
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import svcs
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from pydantic import Field, SecretStr, computed_field
|
|
13
|
+
from pydantic_settings import SettingsConfigDict
|
|
14
|
+
from structlog.stdlib import get_logger
|
|
15
|
+
|
|
16
|
+
from ..core.extensions import BaseExtension, HasCLI
|
|
17
|
+
from ..core.settings import CuneusBaseSettings, DEFAULT_TOOL_NAME
|
|
18
|
+
from ..dependencies import Dependency, check_dependencies
|
|
19
|
+
|
|
20
|
+
check_dependencies(
|
|
21
|
+
"cuneus.ext.database",
|
|
22
|
+
Dependency("sqlalchemy"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from sqlalchemy import URL, make_url, text
|
|
26
|
+
from sqlalchemy.ext.asyncio import (
|
|
27
|
+
AsyncEngine,
|
|
28
|
+
AsyncSession,
|
|
29
|
+
async_sessionmaker,
|
|
30
|
+
create_async_engine,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DatabaseSettings(CuneusBaseSettings):
|
|
37
|
+
"""Database configuration."""
|
|
38
|
+
|
|
39
|
+
model_config = SettingsConfigDict(
|
|
40
|
+
env_prefix="DATABASE_",
|
|
41
|
+
env_file=".env",
|
|
42
|
+
env_file_encoding="utf-8",
|
|
43
|
+
extra="ignore",
|
|
44
|
+
pyproject_toml_depth=2,
|
|
45
|
+
pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME, "database"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Option 1: Full URL (takes precedence if set)
|
|
49
|
+
url: str | None = None
|
|
50
|
+
|
|
51
|
+
# Option 2: Individual parts
|
|
52
|
+
driver: str = "postgresql+asyncpg"
|
|
53
|
+
host: str = "localhost"
|
|
54
|
+
port: int = 5432
|
|
55
|
+
name: str = "app"
|
|
56
|
+
username: str | None = None
|
|
57
|
+
password: SecretStr | None = None
|
|
58
|
+
|
|
59
|
+
# Pool settings
|
|
60
|
+
pool_size: int = 5
|
|
61
|
+
pool_max_overflow: int = 10
|
|
62
|
+
pool_recycle: int = 3600
|
|
63
|
+
echo: bool = False
|
|
64
|
+
|
|
65
|
+
# Alembic
|
|
66
|
+
alembic_config: Path = Path("alembic.ini")
|
|
67
|
+
|
|
68
|
+
@computed_field
|
|
69
|
+
@property
|
|
70
|
+
def url_parsed(self) -> URL:
|
|
71
|
+
"""Get SQLAlchemy URL, either from url string or constructed from parts."""
|
|
72
|
+
if self.url:
|
|
73
|
+
return make_url(self.url)
|
|
74
|
+
|
|
75
|
+
needs_opts = "sqlite" not in self.driver
|
|
76
|
+
password_value = self.password.get_secret_value() if self.password else None
|
|
77
|
+
password = password_value if needs_opts else None
|
|
78
|
+
|
|
79
|
+
return URL.create(
|
|
80
|
+
drivername=self.driver,
|
|
81
|
+
username=self.username if needs_opts else None,
|
|
82
|
+
password=password,
|
|
83
|
+
host=self.host if needs_opts else None,
|
|
84
|
+
port=self.port if needs_opts else None,
|
|
85
|
+
database=self.name,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@computed_field
|
|
89
|
+
@property
|
|
90
|
+
def url_redacted(self) -> str:
|
|
91
|
+
"""URL safe for logging (password hidden)."""
|
|
92
|
+
return self.url_parsed.render_as_string(hide_password=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DatabaseExtension(BaseExtension, HasCLI):
|
|
96
|
+
"""
|
|
97
|
+
Database extension providing AsyncSession via svcs.
|
|
98
|
+
|
|
99
|
+
Registers:
|
|
100
|
+
- AsyncEngine: The SQLAlchemy async engine
|
|
101
|
+
- async_sessionmaker: Factory for creating sessions
|
|
102
|
+
- AsyncSession: Request-scoped session (via factory)
|
|
103
|
+
|
|
104
|
+
CLI Commands:
|
|
105
|
+
- db upgrade [revision]: Run migrations
|
|
106
|
+
- db downgrade [revision]: Rollback migrations
|
|
107
|
+
- db revision -m "message": Create new migration
|
|
108
|
+
- db current: Show current revision
|
|
109
|
+
- db history: Show migration history
|
|
110
|
+
- db check: Check database connectivity
|
|
111
|
+
|
|
112
|
+
Configuration (env or pyproject.toml [tool.cuneus.database]):
|
|
113
|
+
DATABASE_URL: Connection string
|
|
114
|
+
DATABASE_POOL_SIZE: Connection pool size (default: 5)
|
|
115
|
+
DATABASE_POOL_MAX_OVERFLOW: Max overflow connections (default: 10)
|
|
116
|
+
DATABASE_POOL_RECYCLE: Connection recycle time in seconds (default: 3600)
|
|
117
|
+
DATABASE_ECHO: Echo SQL statements (default: false)
|
|
118
|
+
DATABASE_ALEMBIC_CONFIG: Path to alembic.ini (default: alembic.ini)
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_session_factory: async_sessionmaker[AsyncSession]
|
|
122
|
+
_engine: AsyncEngine
|
|
123
|
+
|
|
124
|
+
def __init__(self, settings: DatabaseSettings | None = None):
|
|
125
|
+
self.settings = settings or DatabaseSettings()
|
|
126
|
+
|
|
127
|
+
@asynccontextmanager
|
|
128
|
+
async def register(
|
|
129
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
130
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
131
|
+
self._engine = create_async_engine(
|
|
132
|
+
self.settings.url_parsed,
|
|
133
|
+
# pool_size=self.settings.pool_size,
|
|
134
|
+
# max_overflow=self.settings.pool_max_overflow,
|
|
135
|
+
pool_recycle=self.settings.pool_recycle,
|
|
136
|
+
echo=self.settings.echo,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
self._session_factory = async_sessionmaker(
|
|
140
|
+
self._engine,
|
|
141
|
+
class_=AsyncSession,
|
|
142
|
+
expire_on_commit=False,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
registry.register_value(AsyncEngine, self._engine, ping=self._check)
|
|
146
|
+
registry.register_value(async_sessionmaker, self._session_factory)
|
|
147
|
+
|
|
148
|
+
@asynccontextmanager
|
|
149
|
+
async def session_factory() -> AsyncIterator[AsyncSession]:
|
|
150
|
+
async with self._session_factory() as session:
|
|
151
|
+
try:
|
|
152
|
+
yield session
|
|
153
|
+
await session.commit()
|
|
154
|
+
except Exception:
|
|
155
|
+
await session.rollback()
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
registry.register_factory(AsyncSession, session_factory)
|
|
159
|
+
|
|
160
|
+
logger.info("Database started", extra={"url": self.settings.url_redacted})
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
yield {
|
|
164
|
+
"db_engine": self._engine,
|
|
165
|
+
"db_session_factory": self._session_factory,
|
|
166
|
+
}
|
|
167
|
+
finally:
|
|
168
|
+
await self._engine.dispose()
|
|
169
|
+
logger.info("Database shutdown")
|
|
170
|
+
|
|
171
|
+
async def _check(self):
|
|
172
|
+
engine = create_async_engine(self.settings.url_parsed)
|
|
173
|
+
try:
|
|
174
|
+
async with engine.connect() as conn:
|
|
175
|
+
await conn.execute(text("SELECT 1"))
|
|
176
|
+
finally:
|
|
177
|
+
await engine.dispose()
|
|
178
|
+
|
|
179
|
+
def register_cli(self, cli_group: click.Group) -> None:
|
|
180
|
+
settings = self.settings
|
|
181
|
+
|
|
182
|
+
@cli_group.group()
|
|
183
|
+
def db():
|
|
184
|
+
"""Database management commands."""
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
@db.command()
|
|
188
|
+
@click.argument("revision", default="head")
|
|
189
|
+
def upgrade(revision: str):
|
|
190
|
+
"""Upgrade database to revision (default: head)."""
|
|
191
|
+
_run_alembic_cmd("upgrade", settings.alembic_config, revision=revision)
|
|
192
|
+
|
|
193
|
+
@db.command()
|
|
194
|
+
@click.argument("revision", default="-1")
|
|
195
|
+
def downgrade(revision: str):
|
|
196
|
+
"""Downgrade database to revision (default: -1)."""
|
|
197
|
+
_run_alembic_cmd("downgrade", settings.alembic_config, revision=revision)
|
|
198
|
+
|
|
199
|
+
@db.command()
|
|
200
|
+
@click.option("-m", "--message", required=True, help="Migration message")
|
|
201
|
+
@click.option("--autogenerate/--no-autogenerate", default=True)
|
|
202
|
+
def revision(message: str, autogenerate: bool):
|
|
203
|
+
"""Create a new migration revision."""
|
|
204
|
+
_run_alembic_cmd(
|
|
205
|
+
"revision",
|
|
206
|
+
settings.alembic_config,
|
|
207
|
+
message=message,
|
|
208
|
+
autogenerate=autogenerate,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@db.command()
|
|
212
|
+
def current():
|
|
213
|
+
"""Show current database revision."""
|
|
214
|
+
_run_alembic_cmd("current", settings.alembic_config)
|
|
215
|
+
|
|
216
|
+
@db.command()
|
|
217
|
+
def history():
|
|
218
|
+
"""Show migration history."""
|
|
219
|
+
_run_alembic_cmd("history", settings.alembic_config)
|
|
220
|
+
|
|
221
|
+
@db.command()
|
|
222
|
+
@click.argument("template", default="async")
|
|
223
|
+
def init():
|
|
224
|
+
"""
|
|
225
|
+
Create a new alembic setup by default this will use the async template
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
@db.command()
|
|
229
|
+
@click.pass_context
|
|
230
|
+
def check(ctx: click.Context):
|
|
231
|
+
"""Check database connectivity."""
|
|
232
|
+
import asyncio
|
|
233
|
+
|
|
234
|
+
async def _check():
|
|
235
|
+
engine = create_async_engine(settings.url_parsed)
|
|
236
|
+
try:
|
|
237
|
+
async with engine.connect() as conn:
|
|
238
|
+
await conn.execute(text("SELECT 1"))
|
|
239
|
+
click.echo("✓ Database connection OK")
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(e)
|
|
242
|
+
click.echo(f"✗ Database connection failed: {e}", err=True)
|
|
243
|
+
ctx.exit(1)
|
|
244
|
+
finally:
|
|
245
|
+
await engine.dispose()
|
|
246
|
+
|
|
247
|
+
asyncio.run(_check())
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _run_alembic_cmd(
|
|
251
|
+
cmd: str,
|
|
252
|
+
config_path: Path,
|
|
253
|
+
revision: str | None = None,
|
|
254
|
+
message: str | None = None,
|
|
255
|
+
autogenerate: bool = False,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Run an alembic command."""
|
|
258
|
+
from alembic import command
|
|
259
|
+
from alembic.config import Config
|
|
260
|
+
|
|
261
|
+
if not config_path.exists():
|
|
262
|
+
raise click.ClickException(f"Alembic config not found: {config_path}")
|
|
263
|
+
|
|
264
|
+
cfg = Config(str(config_path))
|
|
265
|
+
|
|
266
|
+
match cmd:
|
|
267
|
+
case "upgrade":
|
|
268
|
+
command.upgrade(cfg, revision or "head")
|
|
269
|
+
case "downgrade":
|
|
270
|
+
command.downgrade(cfg, revision or "-1")
|
|
271
|
+
case "revision":
|
|
272
|
+
command.revision(cfg, message=message, autogenerate=autogenerate)
|
|
273
|
+
case "current":
|
|
274
|
+
command.current(cfg)
|
|
275
|
+
case "history":
|
|
276
|
+
command.history(cfg)
|
|
277
|
+
case _:
|
|
278
|
+
raise click.ClickException(f"Unknown command: {cmd}")
|
cuneus/ext/health.py
CHANGED
|
@@ -9,13 +9,14 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
import structlog
|
|
11
11
|
import svcs
|
|
12
|
-
from fastapi import APIRouter, FastAPI
|
|
12
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
15
|
from ..core.extensions import BaseExtension
|
|
16
16
|
from ..core.settings import Settings
|
|
17
17
|
|
|
18
18
|
log = structlog.get_logger()
|
|
19
|
+
health_router = APIRouter()
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class HealthStatus(str, Enum):
|
|
@@ -35,95 +36,91 @@ class HealthResponse(BaseModel):
|
|
|
35
36
|
services: list[ServiceHealth] = []
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
@health_router.get("", response_model=HealthResponse)
|
|
40
|
+
async def health(services: svcs.fastapi.DepContainer, request: Request) -> HealthResponse:
|
|
41
|
+
"""Full health check - pings all registered services."""
|
|
42
|
+
pings = services.get_pings()
|
|
43
|
+
|
|
44
|
+
_services: list[ServiceHealth] = []
|
|
45
|
+
overall_healthy = True
|
|
46
|
+
|
|
47
|
+
for ping in pings:
|
|
48
|
+
try:
|
|
49
|
+
await ping.aping()
|
|
50
|
+
_services.append(
|
|
51
|
+
ServiceHealth(
|
|
52
|
+
name=ping.name,
|
|
53
|
+
status=HealthStatus.HEALTHY,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
58
|
+
_services.append(
|
|
59
|
+
ServiceHealth(
|
|
60
|
+
name=ping.name,
|
|
61
|
+
status=HealthStatus.UNHEALTHY,
|
|
62
|
+
message=str(e),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
overall_healthy = False
|
|
66
|
+
|
|
67
|
+
return HealthResponse(
|
|
68
|
+
status=(HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY),
|
|
69
|
+
version=getattr(request.state, "version", "NA"),
|
|
70
|
+
services=_services,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@health_router.get("/live")
|
|
75
|
+
async def liveness() -> dict[str, str]:
|
|
76
|
+
"""Liveness probe - is the process running?"""
|
|
77
|
+
return {"status": "ok"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@health_router.get("/ready")
|
|
81
|
+
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
82
|
+
"""Readiness probe - can we serve traffic?"""
|
|
83
|
+
from fastapi import HTTPException
|
|
84
|
+
|
|
85
|
+
pings = services.get_pings()
|
|
86
|
+
|
|
87
|
+
for ping in pings:
|
|
88
|
+
try:
|
|
89
|
+
await ping.aping()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
log.warning("readiness_check_failed", service=ping.name, error=str(e))
|
|
92
|
+
raise HTTPException(status_code=503, detail=f"{ping.name} unhealthy")
|
|
93
|
+
|
|
94
|
+
return {"status": "ok"}
|
|
95
|
+
|
|
96
|
+
|
|
38
97
|
class HealthExtension(BaseExtension):
|
|
39
98
|
"""
|
|
40
99
|
Health check extension using svcs pings.
|
|
41
100
|
|
|
42
101
|
Adds:
|
|
43
|
-
GET /
|
|
44
|
-
GET /
|
|
45
|
-
GET /
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
app = build_app(
|
|
52
|
-
settings,
|
|
53
|
-
extensions=[HealthExtension(settings)],
|
|
54
|
-
)
|
|
102
|
+
GET /healthz - Full health check using svcs pings
|
|
103
|
+
GET /healthz/live - Liveness probe (always 200)
|
|
104
|
+
GET /healthz/ready - Readiness probe (503 if unhealthy)
|
|
105
|
+
|
|
106
|
+
Settings:
|
|
107
|
+
- `health_enabled`: bool to disable health routes, default True
|
|
108
|
+
- `health_prefix`: prefix to include routes default: '/healthz'
|
|
109
|
+
- `version`: used to display in the response
|
|
55
110
|
"""
|
|
56
111
|
|
|
57
112
|
def __init__(self, settings: Settings | None = None) -> None:
|
|
58
113
|
self.settings = settings or Settings()
|
|
59
114
|
|
|
60
115
|
async def startup(self, registry: svcs.Registry, app: FastAPI) -> dict[str, Any]:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
_services: list[ServiceHealth] = []
|
|
73
|
-
overall_healthy = True
|
|
74
|
-
|
|
75
|
-
for ping in pings:
|
|
76
|
-
try:
|
|
77
|
-
await ping.aping()
|
|
78
|
-
_services.append(
|
|
79
|
-
ServiceHealth(
|
|
80
|
-
name=ping.name,
|
|
81
|
-
status=HealthStatus.HEALTHY,
|
|
82
|
-
)
|
|
83
|
-
)
|
|
84
|
-
except Exception as e:
|
|
85
|
-
log.warning("health_check_failed", service=ping.name, error=str(e))
|
|
86
|
-
_services.append(
|
|
87
|
-
ServiceHealth(
|
|
88
|
-
name=ping.name,
|
|
89
|
-
status=HealthStatus.UNHEALTHY,
|
|
90
|
-
message=str(e),
|
|
91
|
-
)
|
|
92
|
-
)
|
|
93
|
-
overall_healthy = False
|
|
94
|
-
|
|
95
|
-
return HealthResponse(
|
|
96
|
-
status=(
|
|
97
|
-
HealthStatus.HEALTHY if overall_healthy else HealthStatus.UNHEALTHY
|
|
98
|
-
),
|
|
99
|
-
version=version,
|
|
100
|
-
services=_services,
|
|
116
|
+
state = await super().startup(registry, app)
|
|
117
|
+
state["version"] = self.settings.version
|
|
118
|
+
return state
|
|
119
|
+
|
|
120
|
+
def add_routes(self, app: FastAPI) -> None:
|
|
121
|
+
if self.settings.health_enabled:
|
|
122
|
+
app.include_router(
|
|
123
|
+
health_router,
|
|
124
|
+
prefix=self.settings.health_prefix,
|
|
125
|
+
tags=["health"],
|
|
101
126
|
)
|
|
102
|
-
|
|
103
|
-
@router.get("/live")
|
|
104
|
-
async def liveness() -> dict[str, str]:
|
|
105
|
-
"""Liveness probe - is the process running?"""
|
|
106
|
-
return {"status": "ok"}
|
|
107
|
-
|
|
108
|
-
@router.get("/ready")
|
|
109
|
-
async def readiness(services: svcs.fastapi.DepContainer) -> dict[str, str]:
|
|
110
|
-
"""Readiness probe - can we serve traffic?"""
|
|
111
|
-
from fastapi import HTTPException
|
|
112
|
-
|
|
113
|
-
pings = services.get_pings()
|
|
114
|
-
|
|
115
|
-
for ping in pings:
|
|
116
|
-
try:
|
|
117
|
-
await ping.aping()
|
|
118
|
-
except Exception as e:
|
|
119
|
-
log.warning(
|
|
120
|
-
"readiness_check_failed", service=ping.name, error=str(e)
|
|
121
|
-
)
|
|
122
|
-
raise HTTPException(
|
|
123
|
-
status_code=503, detail=f"{ping.name} unhealthy"
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
return {"status": "ok"}
|
|
127
|
-
|
|
128
|
-
app.include_router(router)
|
|
129
|
-
return {}
|
cuneus/ext/otel.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# cuneus/ext/otel.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any, AsyncIterator, Callable
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
import svcs
|
|
9
|
+
from fastapi import FastAPI, Request, Response
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_settings import SettingsConfigDict
|
|
12
|
+
from starlette.middleware import Middleware
|
|
13
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
|
+
|
|
15
|
+
from ..core.extensions import BaseExtension, HasMiddleware
|
|
16
|
+
from ..core.settings import CuneusBaseSettings, DEFAULT_TOOL_NAME
|
|
17
|
+
from ..dependencies import Dependency, check_dependencies
|
|
18
|
+
|
|
19
|
+
check_dependencies(
|
|
20
|
+
"cuneus.ext.otel",
|
|
21
|
+
Dependency("opentelemetry.sdk", "opentelemetry-sdk"),
|
|
22
|
+
Dependency("opentelemetry.trace", "opentelemetry-api"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from opentelemetry import trace, metrics
|
|
26
|
+
from opentelemetry.sdk.trace import TracerProvider, SpanProcessor
|
|
27
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
|
28
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
29
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
|
|
30
|
+
from opentelemetry.trace import Tracer, Status, StatusCode
|
|
31
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
32
|
+
from opentelemetry.propagate import set_global_textmap
|
|
33
|
+
|
|
34
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OTelSettings(CuneusBaseSettings):
|
|
38
|
+
"""OpenTelemetry configuration."""
|
|
39
|
+
|
|
40
|
+
model_config = SettingsConfigDict(
|
|
41
|
+
env_prefix="OTEL_",
|
|
42
|
+
env_file=".env",
|
|
43
|
+
env_file_encoding="utf-8",
|
|
44
|
+
extra="ignore",
|
|
45
|
+
pyproject_toml_depth=2,
|
|
46
|
+
pyproject_toml_table_header=("tool", DEFAULT_TOOL_NAME, "otel"),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Service identity
|
|
50
|
+
service_name: str = "unknown-service"
|
|
51
|
+
service_version: str = "0.0.0"
|
|
52
|
+
environment: str = "development"
|
|
53
|
+
|
|
54
|
+
# Feature flags
|
|
55
|
+
enabled: bool = True
|
|
56
|
+
traces_enabled: bool = True
|
|
57
|
+
metrics_enabled: bool = True
|
|
58
|
+
|
|
59
|
+
# Auto-instrumentation
|
|
60
|
+
instrument_fastapi: bool = True
|
|
61
|
+
instrument_sqlalchemy: bool = True
|
|
62
|
+
instrument_httpx: bool = True
|
|
63
|
+
instrument_redis: bool = True
|
|
64
|
+
instrument_logging: bool = True
|
|
65
|
+
|
|
66
|
+
# Exporter
|
|
67
|
+
exporter_otlp_endpoint: str | None = None
|
|
68
|
+
|
|
69
|
+
# Sampling
|
|
70
|
+
sample_rate: float = 1.0
|
|
71
|
+
|
|
72
|
+
# Excluded paths
|
|
73
|
+
excluded_paths: list[str] = Field(
|
|
74
|
+
default_factory=lambda: ["/health", "/ready", "/metrics", "/favicon.ico"]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OTelExtension(BaseExtension, HasMiddleware):
|
|
79
|
+
"""
|
|
80
|
+
OpenTelemetry extension providing distributed tracing and metrics.
|
|
81
|
+
|
|
82
|
+
Registers:
|
|
83
|
+
- TracerProvider: The global tracer provider
|
|
84
|
+
- Tracer: A tracer instance for the service
|
|
85
|
+
- MeterProvider: The global meter provider (if metrics enabled)
|
|
86
|
+
|
|
87
|
+
Auto-instrumentation (configurable):
|
|
88
|
+
- FastAPI/Starlette
|
|
89
|
+
- SQLAlchemy
|
|
90
|
+
- HTTPX
|
|
91
|
+
- Redis
|
|
92
|
+
- Logging
|
|
93
|
+
|
|
94
|
+
Configuration (env with OTEL_ prefix or pyproject.toml [tool.cuneus.otel]):
|
|
95
|
+
service_name: Service name for traces
|
|
96
|
+
service_version: Service version
|
|
97
|
+
environment: Deployment environment
|
|
98
|
+
enabled: Enable/disable OTel entirely (default: true)
|
|
99
|
+
traces_enabled: Enable tracing (default: true)
|
|
100
|
+
metrics_enabled: Enable metrics (default: true)
|
|
101
|
+
instrument_*: Enable specific auto-instrumentation
|
|
102
|
+
exporter_otlp_endpoint: OTLP exporter endpoint
|
|
103
|
+
sample_rate: Sampling rate 0.0-1.0 (default: 1.0)
|
|
104
|
+
excluded_paths: Paths to exclude from tracing
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_tracer_provider: TracerProvider
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
settings: OTelSettings | None = None,
|
|
112
|
+
span_exporters: list[SpanExporter] | None = None,
|
|
113
|
+
span_processors: list[SpanProcessor] | None = None,
|
|
114
|
+
):
|
|
115
|
+
self.settings = settings or OTelSettings()
|
|
116
|
+
self._span_exporters = span_exporters or []
|
|
117
|
+
self._span_processors = span_processors or []
|
|
118
|
+
|
|
119
|
+
@asynccontextmanager
|
|
120
|
+
async def register(
|
|
121
|
+
self, registry: svcs.Registry, app: FastAPI
|
|
122
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
123
|
+
if not self.settings.enabled:
|
|
124
|
+
logger.info("OpenTelemetry disabled")
|
|
125
|
+
yield {}
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
resource = Resource.create(
|
|
129
|
+
{
|
|
130
|
+
SERVICE_NAME: self.settings.service_name,
|
|
131
|
+
SERVICE_VERSION: self.settings.service_version,
|
|
132
|
+
"deployment.environment": self.settings.environment,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if self.settings.traces_enabled:
|
|
137
|
+
self._tracer_provider = TracerProvider(resource=resource)
|
|
138
|
+
|
|
139
|
+
for processor in self._span_processors:
|
|
140
|
+
self._tracer_provider.add_span_processor(processor)
|
|
141
|
+
|
|
142
|
+
for exporter in self._span_exporters:
|
|
143
|
+
self._tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
144
|
+
|
|
145
|
+
trace.set_tracer_provider(self._tracer_provider)
|
|
146
|
+
set_global_textmap(TraceContextTextMapPropagator())
|
|
147
|
+
|
|
148
|
+
registry.register_value(TracerProvider, self._tracer_provider)
|
|
149
|
+
registry.register_value(
|
|
150
|
+
Tracer,
|
|
151
|
+
self._tracer_provider.get_tracer(self.settings.service_name),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self._setup_auto_instrumentation()
|
|
155
|
+
|
|
156
|
+
logger.info(
|
|
157
|
+
"OpenTelemetry tracing started",
|
|
158
|
+
extra={
|
|
159
|
+
"service": self.settings.service_name,
|
|
160
|
+
"exporters": len(self._span_exporters),
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if self.settings.metrics_enabled:
|
|
165
|
+
meter_provider = MeterProvider(resource=resource)
|
|
166
|
+
metrics.set_meter_provider(meter_provider)
|
|
167
|
+
registry.register_value(MeterProvider, meter_provider)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
yield {"tracer_provider": self._tracer_provider}
|
|
171
|
+
finally:
|
|
172
|
+
if self._tracer_provider:
|
|
173
|
+
self._tracer_provider.shutdown()
|
|
174
|
+
logger.info("OpenTelemetry shutdown")
|
|
175
|
+
|
|
176
|
+
def middleware(self) -> list[Middleware]:
|
|
177
|
+
if not self.settings.enabled or not self.settings.traces_enabled:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
return [
|
|
181
|
+
Middleware(
|
|
182
|
+
OTelMiddleware,
|
|
183
|
+
excluded_paths=set(self.settings.excluded_paths),
|
|
184
|
+
)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
def _setup_auto_instrumentation(self) -> None:
|
|
188
|
+
"""Setup auto-instrumentation based on settings."""
|
|
189
|
+
instrumentors = [
|
|
190
|
+
(
|
|
191
|
+
self.settings.instrument_fastapi,
|
|
192
|
+
"opentelemetry.instrumentation.fastapi",
|
|
193
|
+
"FastAPIInstrumentor",
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
self.settings.instrument_sqlalchemy,
|
|
197
|
+
"opentelemetry.instrumentation.sqlalchemy",
|
|
198
|
+
"SQLAlchemyInstrumentor",
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
self.settings.instrument_httpx,
|
|
202
|
+
"opentelemetry.instrumentation.httpx",
|
|
203
|
+
"HTTPXClientInstrumentor",
|
|
204
|
+
),
|
|
205
|
+
(
|
|
206
|
+
self.settings.instrument_redis,
|
|
207
|
+
"opentelemetry.instrumentation.redis",
|
|
208
|
+
"RedisInstrumentor",
|
|
209
|
+
),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
for enabled, module, class_name in instrumentors:
|
|
213
|
+
if enabled:
|
|
214
|
+
self._try_instrument(module, class_name)
|
|
215
|
+
|
|
216
|
+
def _try_instrument(self, module: str, class_name: str) -> None:
|
|
217
|
+
try:
|
|
218
|
+
import importlib
|
|
219
|
+
|
|
220
|
+
mod = importlib.import_module(module)
|
|
221
|
+
instrumentor = getattr(mod, class_name)()
|
|
222
|
+
instrumentor.instrument()
|
|
223
|
+
logger.debug(f"{class_name} auto-instrumentation enabled")
|
|
224
|
+
except ImportError:
|
|
225
|
+
logger.debug(f"{class_name} instrumentation not available")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"{class_name} instrumentation failed: {e}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class OTelMiddleware(BaseHTTPMiddleware):
|
|
231
|
+
"""Middleware to enrich spans and logs with trace context."""
|
|
232
|
+
|
|
233
|
+
def __init__(self, app, excluded_paths: set[str] | None = None):
|
|
234
|
+
super().__init__(app)
|
|
235
|
+
self.excluded_paths = excluded_paths or set()
|
|
236
|
+
|
|
237
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
238
|
+
if request.url.path in self.excluded_paths:
|
|
239
|
+
return await call_next(request)
|
|
240
|
+
|
|
241
|
+
span = trace.get_current_span()
|
|
242
|
+
ctx = span.get_span_context()
|
|
243
|
+
|
|
244
|
+
# Bind trace context to structlog for this request
|
|
245
|
+
if ctx.is_valid:
|
|
246
|
+
structlog.contextvars.bind_contextvars(
|
|
247
|
+
trace_id=format(ctx.trace_id, "032x"),
|
|
248
|
+
span_id=format(ctx.span_id, "016x"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if span.is_recording():
|
|
252
|
+
# span.set_attribute("http.client_ip", _get_client_ip(request))
|
|
253
|
+
span.set_attribute("http.user_agent", request.headers.get("user-agent", ""))
|
|
254
|
+
|
|
255
|
+
if request.url.query:
|
|
256
|
+
span.set_attribute("http.query_string", str(request.url.query))
|
|
257
|
+
|
|
258
|
+
if request_id := request.headers.get("x-request-id"):
|
|
259
|
+
span.set_attribute("http.request_id", request_id)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
response = await call_next(request)
|
|
263
|
+
|
|
264
|
+
if span.is_recording():
|
|
265
|
+
if content_length := response.headers.get("content-length"):
|
|
266
|
+
span.set_attribute(
|
|
267
|
+
"http.response_content_length", int(content_length)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return response
|
|
271
|
+
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
if span.is_recording():
|
|
274
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
275
|
+
span.record_exception(exc)
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
finally:
|
|
279
|
+
structlog.contextvars.unbind_contextvars("trace_id", "span_id")
|
cuneus/ext/server.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from ..core.extensions import BaseExtension
|
|
4
|
+
from ..utils import import_from_string
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerExtension(BaseExtension):
|
|
8
|
+
"""Provides dev/prod/routes CLI commands."""
|
|
9
|
+
|
|
10
|
+
def register_cli(self, cli_group: click.Group) -> None:
|
|
11
|
+
settings = self.settings
|
|
12
|
+
|
|
13
|
+
@cli_group.command()
|
|
14
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
15
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
16
|
+
def dev(host: str, port: int) -> None:
|
|
17
|
+
"""Run the application in development mode with reload."""
|
|
18
|
+
import uvicorn
|
|
19
|
+
|
|
20
|
+
uvicorn.run(
|
|
21
|
+
settings.app_module,
|
|
22
|
+
host=host,
|
|
23
|
+
port=port,
|
|
24
|
+
reload=True,
|
|
25
|
+
log_config=None,
|
|
26
|
+
server_header=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@cli_group.command()
|
|
30
|
+
@click.option("--host", default="0.0.0.0", help="Bind host")
|
|
31
|
+
@click.option("--port", default=8000, type=int, help="Bind port")
|
|
32
|
+
@click.option("--workers", default=1, type=int, help="Number of workers")
|
|
33
|
+
def prod(host: str, port: int, workers: int) -> None:
|
|
34
|
+
"""Run the application in production mode."""
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
uvicorn.run(
|
|
38
|
+
settings.app_module,
|
|
39
|
+
host=host,
|
|
40
|
+
port=port,
|
|
41
|
+
workers=workers,
|
|
42
|
+
log_config=None,
|
|
43
|
+
server_header=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@cli_group.command()
|
|
47
|
+
def routes() -> None:
|
|
48
|
+
"""List all registered routes."""
|
|
49
|
+
app = import_from_string(settings.app_module)
|
|
50
|
+
|
|
51
|
+
for route in app.routes:
|
|
52
|
+
if hasattr(route, "methods"): # pragma: no branch
|
|
53
|
+
methods = ",".join(route.methods - {"HEAD", "OPTIONS"})
|
|
54
|
+
click.echo(f"{methods:8} {route.path}")
|
cuneus/utils.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def import_from_string(import_str: str) -> typing.Any:
|
|
6
|
+
"""Import an object from a module:attribute string."""
|
|
7
|
+
module_path, _, attr = import_str.partition(":")
|
|
8
|
+
if not attr:
|
|
9
|
+
raise ValueError(f"module_path missing function {import_str} expecting 'module.path:name'")
|
|
10
|
+
|
|
11
|
+
module = importlib.import_module(module_path)
|
|
12
|
+
return getattr(module, attr)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cuneus
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
4
4
|
Summary: ASGI application wrapper
|
|
5
5
|
Project-URL: Homepage, https://github.com/rmyers/cuneus
|
|
6
6
|
Project-URL: Documentation, https://github.com/rmyers/cuneus#readme
|
|
@@ -24,11 +24,15 @@ Requires-Dist: alembic>=1.13.0; extra == 'database'
|
|
|
24
24
|
Requires-Dist: asyncpg>=0.29.0; extra == 'database'
|
|
25
25
|
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'database'
|
|
26
26
|
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: aiosqlite[dev]>=0.22.1; extra == 'dev'
|
|
27
28
|
Requires-Dist: alembic>=1.13.0; extra == 'dev'
|
|
28
29
|
Requires-Dist: asgi-lifespan>=2.1.0; extra == 'dev'
|
|
29
30
|
Requires-Dist: asyncpg>=0.29.0; extra == 'dev'
|
|
30
31
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
31
32
|
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
33
|
+
Requires-Dist: opentelemetry-api; extra == 'dev'
|
|
34
|
+
Requires-Dist: opentelemetry-instrumentation; extra == 'dev'
|
|
35
|
+
Requires-Dist: opentelemetry-sdk; extra == 'dev'
|
|
32
36
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
37
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
34
38
|
Requires-Dist: pytest-mock; extra == 'dev'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
|
|
2
|
+
cuneus/cli.py,sha256=Me84yXEWpsa5QMDcESKQPZhBgZnFJ3iKBshAXtlj2AM,1490
|
|
3
|
+
cuneus/dependencies.py,sha256=97VAZKdL7MmP-wKAmwQ-AzADYeG4gYAnys_ru-PaqVg,2160
|
|
4
|
+
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
cuneus/utils.py,sha256=A2GN2gWjf3MdJi3bcFRxFWWDEy2t8bQOYdu8N0ZhqAE,401
|
|
6
|
+
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
cuneus/core/application.py,sha256=AFBzaAMX-jJZvapSe-5prvIeaRf2ynJwrKk9Tvk1nkc,4469
|
|
8
|
+
cuneus/core/exceptions.py,sha256=GRtMz4tZZzpA2-Zwx4nBEc5SGcWrrFf0OWFYeHCpU0I,5468
|
|
9
|
+
cuneus/core/extensions.py,sha256=fO9RaJ00Bw9s0VsnCQAAdo-zR9N8573uO3YP2fYUQc4,2934
|
|
10
|
+
cuneus/core/logging.py,sha256=Ql2VDQesZaDRdhua2HLYzvNhkIPVsTGUqxwYBkUI2dc,4581
|
|
11
|
+
cuneus/core/settings.py,sha256=75NRttKkLpT6AxVd6dj3boYq05j5td32-c7fAlTjLsg,2247
|
|
12
|
+
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
cuneus/ext/database.py,sha256=BED5A73U-yVVPxUJe1nuzOBee7D0QnMvcmk8V_3hUNI,8987
|
|
14
|
+
cuneus/ext/health.py,sha256=l561FKnOhlsuCxJF2lSxwiEagdakixoaD78MGz4SL1s,3590
|
|
15
|
+
cuneus/ext/otel.py,sha256=ye4N-WL6xQpxTN8RMj06Q3yQA1CFLrY4J1xsjQjGvUY,9565
|
|
16
|
+
cuneus/ext/server.py,sha256=wyQMiHFZEFYt1a4wC4IjorEp70kH7fF2aLx3_X5t5RI,1869
|
|
17
|
+
cuneus-0.2.10.dist-info/METADATA,sha256=99NJ0rkBSUoc9y-agUxJ5on_oDBdx6s77Fz33rxUQ5Q,7051
|
|
18
|
+
cuneus-0.2.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
19
|
+
cuneus-0.2.10.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
20
|
+
cuneus-0.2.10.dist-info/RECORD,,
|
cuneus-0.2.8.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
|
|
2
|
-
cuneus/cli.py,sha256=rcJDqU28FMPSw7tkmKUz95ERKzgYv43Y2kKVZw24rLo,3864
|
|
3
|
-
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
cuneus/core/application.py,sha256=Xoqs3fREJ-KPzSilq4ua65JzKv6w5LgugGaMklDELdU,4376
|
|
6
|
-
cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
|
|
7
|
-
cuneus/core/extensions.py,sha256=wqN2rbaqhiTkfK_fA5AkFlefOAV2GJr6zTLf9Oi-n4c,2771
|
|
8
|
-
cuneus/core/logging.py,sha256=jWwtPJTNYtvOSb4etg7pglWHAKfV4iRuw4Npoqne_00,4578
|
|
9
|
-
cuneus/core/settings.py,sha256=PaYXQ_ubeSt3AFpxNNErii-h1_ehHYPrajFWRT42mTI,1703
|
|
10
|
-
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
cuneus/ext/health.py,sha256=5dWVVEPFL1tWFBhQwZv8C-IvZRzg28V-4sk_g1jJ0vc,3854
|
|
12
|
-
cuneus-0.2.8.dist-info/METADATA,sha256=Y3d-3jUnaPU_GkOeHpYaxM9AmiEfnjc4WVIo4OZrYjM,6837
|
|
13
|
-
cuneus-0.2.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
-
cuneus-0.2.8.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
15
|
-
cuneus-0.2.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|