cuneus 0.2.9__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 +1 -3
- cuneus/core/application.py +2 -6
- cuneus/core/exceptions.py +2 -6
- cuneus/core/settings.py +0 -1
- cuneus/dependencies.py +79 -0
- cuneus/ext/database.py +278 -0
- cuneus/ext/health.py +1 -3
- cuneus/ext/otel.py +279 -0
- cuneus/utils.py +1 -3
- {cuneus-0.2.9.dist-info → cuneus-0.2.10.dist-info}/METADATA +5 -1
- cuneus-0.2.10.dist-info/RECORD +20 -0
- cuneus-0.2.9.dist-info/RECORD +0 -17
- {cuneus-0.2.9.dist-info → cuneus-0.2.10.dist-info}/WHEEL +0 -0
- {cuneus-0.2.9.dist-info → cuneus-0.2.10.dist-info}/entry_points.txt +0 -0
cuneus/cli.py
CHANGED
|
@@ -13,9 +13,7 @@ def get_user_cli(config: Settings = Settings()) -> click.Group | None:
|
|
|
13
13
|
try:
|
|
14
14
|
return cast(click.Group, import_from_string(config.cli_module))
|
|
15
15
|
except (ImportError, AttributeError) as e:
|
|
16
|
-
click.echo(
|
|
17
|
-
f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True
|
|
18
|
-
)
|
|
16
|
+
click.echo(f"Warning: Could not load CLI from {config.cli_module}: {e}", err=True)
|
|
19
17
|
return None
|
|
20
18
|
|
|
21
19
|
|
cuneus/core/application.py
CHANGED
|
@@ -41,9 +41,7 @@ class ExtensionConflictError(Exception):
|
|
|
41
41
|
pass
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def _instantiate_extension(
|
|
45
|
-
ext: ExtensionInput, settings: Settings | None = None
|
|
46
|
-
) -> Extension:
|
|
44
|
+
def _instantiate_extension(ext: ExtensionInput, settings: Settings | None = None) -> Extension:
|
|
47
45
|
if isinstance(ext, type) or callable(ext):
|
|
48
46
|
try:
|
|
49
47
|
return ext(settings=settings)
|
|
@@ -97,9 +95,7 @@ def build_app(
|
|
|
97
95
|
|
|
98
96
|
@svcs.fastapi.lifespan
|
|
99
97
|
@asynccontextmanager
|
|
100
|
-
async def lifespan(
|
|
101
|
-
app: FastAPI, registry: svcs.Registry
|
|
102
|
-
) -> AsyncIterator[dict[str, Any]]:
|
|
98
|
+
async def lifespan(app: FastAPI, registry: svcs.Registry) -> AsyncIterator[dict[str, Any]]:
|
|
103
99
|
async with AsyncExitStack() as stack:
|
|
104
100
|
state: dict[str, Any] = {}
|
|
105
101
|
|
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/settings.py
CHANGED
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
|
@@ -37,9 +37,7 @@ class HealthResponse(BaseModel):
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
@health_router.get("", response_model=HealthResponse)
|
|
40
|
-
async def health(
|
|
41
|
-
services: svcs.fastapi.DepContainer, request: Request
|
|
42
|
-
) -> HealthResponse:
|
|
40
|
+
async def health(services: svcs.fastapi.DepContainer, request: Request) -> HealthResponse:
|
|
43
41
|
"""Full health check - pings all registered services."""
|
|
44
42
|
pings = services.get_pings()
|
|
45
43
|
|
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/utils.py
CHANGED
|
@@ -6,9 +6,7 @@ def import_from_string(import_str: str) -> typing.Any:
|
|
|
6
6
|
"""Import an object from a module:attribute string."""
|
|
7
7
|
module_path, _, attr = import_str.partition(":")
|
|
8
8
|
if not attr:
|
|
9
|
-
raise ValueError(
|
|
10
|
-
f"module_path missing function {import_str} expecting 'module.path:name'"
|
|
11
|
-
)
|
|
9
|
+
raise ValueError(f"module_path missing function {import_str} expecting 'module.path:name'")
|
|
12
10
|
|
|
13
11
|
module = importlib.import_module(module_path)
|
|
14
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.9.dist-info/RECORD
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
cuneus/__init__.py,sha256=AJXN9dnXIWn0Eg6JxWOgOf0C386EqBNdHiQG2akTpKc,1295
|
|
2
|
-
cuneus/cli.py,sha256=c8QbGj9QLcr-XJNUgCiSeaO1n5oY3DWBOMj2ruAxUwM,1512
|
|
3
|
-
cuneus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
-
cuneus/utils.py,sha256=WyykPUXwxJWpFny5uL87_Fqd2R34za7gXUOf4IyeC0w,423
|
|
5
|
-
cuneus/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
cuneus/core/application.py,sha256=LHFLqdQvIbjsN1iCVkFJkPFFoIWNGWP2DAPjmI3IWaY,4489
|
|
7
|
-
cuneus/core/exceptions.py,sha256=gyHgVy4UC7gBjs5pXpCzIIyIVsV7K8xt-KZKi8eLJtM,5496
|
|
8
|
-
cuneus/core/extensions.py,sha256=fO9RaJ00Bw9s0VsnCQAAdo-zR9N8573uO3YP2fYUQc4,2934
|
|
9
|
-
cuneus/core/logging.py,sha256=Ql2VDQesZaDRdhua2HLYzvNhkIPVsTGUqxwYBkUI2dc,4581
|
|
10
|
-
cuneus/core/settings.py,sha256=cHHqdKtAjcTPBKXnfG9_GMJiTr7t-iX7rPvIQ480UkI,2248
|
|
11
|
-
cuneus/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
cuneus/ext/health.py,sha256=6JG25foR5ln2tnRuGYuTVju8bBv16iVR4xKpdf2OVa0,3596
|
|
13
|
-
cuneus/ext/server.py,sha256=wyQMiHFZEFYt1a4wC4IjorEp70kH7fF2aLx3_X5t5RI,1869
|
|
14
|
-
cuneus-0.2.9.dist-info/METADATA,sha256=90bfNg2zE9iFbH9Rd6luS1txjL6PRutr8e6BZosyyiA,6837
|
|
15
|
-
cuneus-0.2.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
16
|
-
cuneus-0.2.9.dist-info/entry_points.txt,sha256=tzPgom-_UkpP_uLKv3V_XyoIsKg84FBAc9ddjYl0W0Y,43
|
|
17
|
-
cuneus-0.2.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|