diff --git a/.dev/docker-compose.yaml b/.dev/docker-compose.yaml
index 2992270..65d673e 100644
--- a/.dev/docker-compose.yaml
+++ b/.dev/docker-compose.yaml
@@ -13,4 +13,14 @@ services:
image: redis:7-alpine
ports:
- "127.0.0.1:6379:6379"
- restart: unless-stopped
\ No newline at end of file
+ restart: unless-stopped
+
+ # Optional: docker compose -f .dev/docker-compose.yaml --profile telemetry up -d
+ otel-collector:
+ image: otel/opentelemetry-collector-contrib:0.161.0
+ command: ["--config=/etc/otelcol/config.yaml"]
+ volumes:
+ - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
+ ports:
+ - "127.0.0.1:4318:4318"
+ profiles: [telemetry]
diff --git a/.dev/otel-collector.yaml b/.dev/otel-collector.yaml
new file mode 100644
index 0000000..7265e86
--- /dev/null
+++ b/.dev/otel-collector.yaml
@@ -0,0 +1,20 @@
+# Local OpenTelemetry collector: prints what the app exports.
+# Point the app at it with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318.
+receivers:
+ otlp:
+ protocols:
+ http:
+ endpoint: 0.0.0.0:4318
+
+exporters:
+ debug:
+ verbosity: detailed
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ exporters: [debug]
+ metrics:
+ receivers: [otlp]
+ exporters: [debug]
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..65a39bf
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# LF everywhere, on every OS (matches ruff's line-ending = "lf").
+* text=auto eol=lf
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 9cbda87..6aaef9d 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,23 +1,23 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- "name": "App Debug",
- "type": "debugpy",
- "request": "launch",
- "module": "uvicorn",
- "args": [
- "app.main:app",
- "--reload"
- ],
- "jinja": false,
- "justMyCode": false,
- "env": {
- "WCS_ADMIN_TOKEN": "wcsadmin"
- },
- }
- ]
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "App Debug",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "uvicorn",
+ "args": [
+ "app.main:app",
+ "--reload"
+ ],
+ "jinja": false,
+ "justMyCode": false,
+ "env": {
+ "WCS_ADMIN_TOKEN": "wcsadmin"
+ },
+ }
+ ]
}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 8c786d3..4a1df9f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,43 +1,46 @@
-FROM bufbuild/buf:1.66.0 AS buf_installer
-
-# Use a Python image with uv pre-installed
-FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
-
-# Install the project into `/app`
-WORKDIR /app
-COPY . /app
-
-# Copy the buf binary from the buf_installer stage and make it executable
-COPY --from=buf_installer /usr/local/bin/buf /usr/local/bin/buf
-
-RUN chmod +x /usr/local/bin/buf
-RUN buf generate --template buf.gen.yaml
-
-# Enable bytecode compilation
-ENV UV_COMPILE_BYTECODE=1
-
-# Copy from the cache instead of linking since it's a mounted volume
-ENV UV_LINK_MODE=copy
-
-# Install the project's dependencies using the lockfile and settings
-RUN --mount=type=cache,target=/root/.cache/uv \
- --mount=type=bind,source=uv.lock,target=uv.lock \
- --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
- uv sync --locked --no-install-project --no-dev
-
-RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --locked --no-dev
-
-# Place executables in the environment at the front of the path
-ENV PATH="/app/.venv/bin:$PATH"
-
-RUN addgroup --system --gid 1000 wynnsource && adduser --system --uid 1000 --ingroup wynnsource wynnsource
-RUN chown -R 1000:1000 /app
-
-ENV UV_NO_CACHE=1
-
-USER 1000
-
-# Run the FastAPI application
-EXPOSE 8000
-CMD fastapi run --forwarded-allow-ips="127.0.0.1,10.0.0.0/8,[::1]"
\ No newline at end of file
+FROM bufbuild/buf:1.66.0 AS buf_installer
+
+# Use a Python image with uv pre-installed
+FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
+
+# Install the project into `/app`
+WORKDIR /app
+COPY . /app
+
+# Copy the buf binary from the buf_installer stage and make it executable
+COPY --from=buf_installer /usr/local/bin/buf /usr/local/bin/buf
+
+RUN chmod +x /usr/local/bin/buf
+RUN buf generate --template buf.gen.yaml
+
+# Enable bytecode compilation
+ENV UV_COMPILE_BYTECODE=1
+
+# Copy from the cache instead of linking since it's a mounted volume
+ENV UV_LINK_MODE=copy
+
+# Install the project's dependencies using the lockfile and settings
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --locked --no-install-project --no-dev
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --locked --no-dev
+
+# Place executables in the environment at the front of the path
+ENV PATH="/app/.venv/bin:$PATH"
+
+RUN addgroup --system --gid 1000 wynnsource && adduser --system --uid 1000 --ingroup wynnsource wynnsource
+RUN chown -R 1000:1000 /app
+
+ENV UV_NO_CACHE=1
+
+USER 1000
+
+# Run uvicorn directly in exec form: fastapi-cli re-attaches its own rich log
+# handlers after importing the app, bypassing loguru (and LOG_JSON), and the
+# shell form keeps uvicorn from receiving SIGTERM, which skips the telemetry
+# flush on shutdown.
+EXPOSE 8000
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips", "127.0.0.1,10.0.0.0/8,[::1]"]
\ No newline at end of file
diff --git a/README.md b/README.md
index ff526fc..8a9b7cc 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,9 @@ The server component of [WynnSource](https://github.com/WynnSource) — a Wynncr
git clone --recurse-submodules https://github.com/WynnSource/WynnSourceServer.git
cd WynnSourceServer
+# Start PostgreSQL and Redis on localhost (add `--profile telemetry` for a local OTel collector)
+docker compose -f .dev/docker-compose.yaml up -d
+
# Generate protobuf code
buf generate --template buf.gen.yaml
@@ -38,6 +41,22 @@ uv run alembic upgrade head
uv run fastapi dev
```
+### Windows
+
+Everything above works natively on Windows. Differences:
+
+- **Containers without Docker:** `wslc` (WSL containers) has no compose, start the services individually:
+
+ ```powershell
+ wslc run -d --name wcs-db -e POSTGRES_DB=wcs_db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 postgres:15-alpine
+ wslc run -d --name wcs-redis -p 127.0.0.1:6379:6379 redis:7-alpine
+ # Optional local OTel collector (prints spans and metrics: wslc logs -f wcs-otelcol)
+ wslc run -d --name wcs-otelcol -p 127.0.0.1:4318:4318 -v "$PWD\.dev\otel-collector.yaml:/etc/otelcol/config.yaml" otel/opentelemetry-collector-contrib:0.161.0 --config=/etc/otelcol/config.yaml
+ ```
+
+- **Non-UTF-8 console code page** (e.g. GBK on Chinese Windows): `fastapi dev` crashes printing its emoji banner. Set `$env:PYTHONUTF8 = "1"`, or run `uv run uvicorn app.main:app --reload` instead.
+- **A `.venv` created from WSL or a container** contains Linux symlinks (`lib64`) that Windows `uv` cannot remove. Delete it from WSL (`rm -rf .venv`) and run `uv sync` again.
+
### Environment Variables
| Variable | Description | Default |
@@ -51,7 +70,16 @@ uv run fastapi dev
| `REDIS_PORT` | Redis port | `6379` |
| `WCS_ADMIN_TOKEN` | Admin API token | None |
| `LEVEL` | Log level | `DEBUG` |
+| `LOG_JSON` | Log one JSON object per line (with `trace_id`/`span_id`) instead of colored text | `false` |
| `BETA_ALLOWED_VERSIONS` | Comma-separated allowed mod versions | `` |
+| `WCS_SENTRY_DSN` | Sentry DSN; errors and logs only, tagged with the OpenTelemetry trace id | None (disabled) |
+| `WCS_SENTRY_ENVIRONMENT` | Sentry environment | `local` |
+| `WCS_SENTRY_PROFILE_SESSION_SAMPLE_RATE` | Share of processes that run Sentry continuous profiling | `0` |
+| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP collector, e.g. `http://localhost:4318`; enables OpenTelemetry traces and metrics | None (disabled) |
+| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes, e.g. `service.instance.id=…,deployment.environment.name=dev`. Give every replica a distinct `service.instance.id` | — |
+| `K8S_POD_NAME` | Holder name for the cross-replica scheduler job locks | hostname |
+
+Other standard `OTEL_*` variables (`OTEL_TRACES_SAMPLER`, `OTEL_METRIC_EXPORT_INTERVAL`, …) are honored. The default sampler is `parentbased_always_on`: requests follow the caller's sampling decision, scheduler jobs are always traced.
## Deploying to Kubernetes
diff --git a/app/__init__.py b/app/__init__.py
index 6cbe860..1a08626 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1 +1 @@
-# blank __init__.py file to mark the directory as a package for discoverability
+# blank __init__.py file to mark the directory as a package for discoverability
diff --git a/app/config/__init__.py b/app/config/__init__.py
index 8b931ce..2f21a90 100644
--- a/app/config/__init__.py
+++ b/app/config/__init__.py
@@ -1,13 +1,15 @@
-from .admin import ADMIN_CONFIG as ADMIN_CONFIG
-from .db import DB_CONFIG as DB_CONFIG
-from .log import LOG_CONFIG as LOG_CONFIG
-from .sentry import SENTRY_CONFIG as SENTRY_CONFIG
-from .user import USER_CONFIG as USER_CONFIG
-
-__all__ = [
- "ADMIN_CONFIG",
- "DB_CONFIG",
- "LOG_CONFIG",
- "SENTRY_CONFIG",
- "USER_CONFIG",
-]
+from .admin import ADMIN_CONFIG as ADMIN_CONFIG
+from .db import DB_CONFIG as DB_CONFIG
+from .log import LOG_CONFIG as LOG_CONFIG
+from .sentry import SENTRY_CONFIG as SENTRY_CONFIG
+from .telemetry import TELEMETRY_CONFIG as TELEMETRY_CONFIG
+from .user import USER_CONFIG as USER_CONFIG
+
+__all__ = [
+ "ADMIN_CONFIG",
+ "DB_CONFIG",
+ "LOG_CONFIG",
+ "SENTRY_CONFIG",
+ "TELEMETRY_CONFIG",
+ "USER_CONFIG",
+]
diff --git a/app/config/admin.py b/app/config/admin.py
index c17bd69..bed1c4c 100644
--- a/app/config/admin.py
+++ b/app/config/admin.py
@@ -1,17 +1,17 @@
-from pydantic import Field
-from pydantic_settings import BaseSettings
-
-
-class AdminConfig(BaseSettings):
- """
- Configuration for the admin interface.
- """
-
- token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None)
-
-
-ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable
-
-__all__ = [
- "ADMIN_CONFIG",
-]
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class AdminConfig(BaseSettings):
+ """
+ Configuration for the admin interface.
+ """
+
+ token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None)
+
+
+ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable
+
+__all__ = [
+ "ADMIN_CONFIG",
+]
diff --git a/app/config/db.py b/app/config/db.py
index 137654b..9d2da81 100644
--- a/app/config/db.py
+++ b/app/config/db.py
@@ -1,44 +1,44 @@
-from typing import Annotated
-
-from pydantic import Field, PostgresDsn, RedisDsn, computed_field
-from pydantic_settings import BaseSettings
-
-
-class DbConfig(BaseSettings):
- postgres_host: Annotated[str, Field(alias="POSTGRES_HOST")] = "localhost"
- postgres_port: Annotated[int, Field(alias="POSTGRES_PORT")] = 5432
- postgres_user: Annotated[str, Field(alias="POSTGRES_USER")] = "postgres"
- postgres_password: Annotated[str, Field(alias="POSTGRES_PASSWORD")] = "postgres"
- postgres_db: Annotated[str, Field(alias="POSTGRES_DB")] = "wcs_db"
-
- redis_host: Annotated[str, Field(alias="REDIS_HOST")] = "localhost"
- redis_port: Annotated[int, Field(alias="REDIS_PORT")] = 6379
-
- @computed_field
- @property
- def postgres_dsn(self) -> PostgresDsn:
- return PostgresDsn.build(
- scheme="postgresql+asyncpg",
- username=self.postgres_user,
- password=self.postgres_password,
- host=self.postgres_host,
- port=self.postgres_port,
- path=self.postgres_db,
- )
-
- @computed_field
- @property
- def redis_dsn(self) -> RedisDsn:
- return RedisDsn.build(
- scheme="redis",
- host=self.redis_host,
- port=self.redis_port,
- path="0",
- )
-
-
-DB_CONFIG = DbConfig()
-
-__all__ = [
- "DB_CONFIG",
-]
+from typing import Annotated
+
+from pydantic import Field, PostgresDsn, RedisDsn, computed_field
+from pydantic_settings import BaseSettings
+
+
+class DbConfig(BaseSettings):
+ postgres_host: Annotated[str, Field(alias="POSTGRES_HOST")] = "localhost"
+ postgres_port: Annotated[int, Field(alias="POSTGRES_PORT")] = 5432
+ postgres_user: Annotated[str, Field(alias="POSTGRES_USER")] = "postgres"
+ postgres_password: Annotated[str, Field(alias="POSTGRES_PASSWORD")] = "postgres"
+ postgres_db: Annotated[str, Field(alias="POSTGRES_DB")] = "wcs_db"
+
+ redis_host: Annotated[str, Field(alias="REDIS_HOST")] = "localhost"
+ redis_port: Annotated[int, Field(alias="REDIS_PORT")] = 6379
+
+ @computed_field
+ @property
+ def postgres_dsn(self) -> PostgresDsn:
+ return PostgresDsn.build(
+ scheme="postgresql+asyncpg",
+ username=self.postgres_user,
+ password=self.postgres_password,
+ host=self.postgres_host,
+ port=self.postgres_port,
+ path=self.postgres_db,
+ )
+
+ @computed_field
+ @property
+ def redis_dsn(self) -> RedisDsn:
+ return RedisDsn.build(
+ scheme="redis",
+ host=self.redis_host,
+ port=self.redis_port,
+ path="0",
+ )
+
+
+DB_CONFIG = DbConfig()
+
+__all__ = [
+ "DB_CONFIG",
+]
diff --git a/app/config/log.py b/app/config/log.py
index a8a4cb2..54c06cf 100644
--- a/app/config/log.py
+++ b/app/config/log.py
@@ -1,21 +1,25 @@
-from pydantic_settings import BaseSettings
-
-
-class LoggerConfig(BaseSettings):
- """
- Logger configuration.
- """
-
- level: str = "DEBUG"
- format: str = (
- "{time:YY-MM-DD HH:mm:ss}"
- + "[{level: <8}]"
- + "{name}:{line}"
- + " | {message}"
- )
-
-
-LOG_CONFIG = LoggerConfig()
-__all__ = [
- "LOG_CONFIG",
-]
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class LoggerConfig(BaseSettings):
+ """
+ Logger configuration.
+ """
+
+ level: str = "DEBUG"
+ format: str = (
+ "{time:YY-MM-DD HH:mm:ss}"
+ + "[{level: <8}]"
+ + "{name}:{line}"
+ + " | {message}"
+ + "{extra[trace]}"
+ )
+ # One JSON object per line instead of the text format above (deployments).
+ json_output: bool = Field(alias="LOG_JSON", default=False)
+
+
+LOG_CONFIG = LoggerConfig()
+__all__ = [
+ "LOG_CONFIG",
+]
diff --git a/app/config/sentry.py b/app/config/sentry.py
index b21b6fd..3857a39 100644
--- a/app/config/sentry.py
+++ b/app/config/sentry.py
@@ -9,8 +9,9 @@ class SentryConfig(BaseSettings):
dsn: str | None = Field(alias="WCS_SENTRY_DSN", default=None)
environment: str = Field(alias="WCS_SENTRY_ENVIRONMENT", default="local")
- traces_sample_rate: float = Field(alias="WCS_SENTRY_TRACES_SAMPLE_RATE", default=0.1)
- profiles_sample_rate: float = Field(alias="WCS_SENTRY_PROFILES_SAMPLE_RATE", default=0.1)
+ # Tracing is OpenTelemetry's job; Sentry only profiles. Decided once per
+ # process: a sampled process profiles continuously for its whole life.
+ profile_session_sample_rate: float = Field(alias="WCS_SENTRY_PROFILE_SESSION_SAMPLE_RATE", default=0.0)
SENTRY_CONFIG = SentryConfig()
diff --git a/app/config/telemetry.py b/app/config/telemetry.py
new file mode 100644
index 0000000..38d9cce
--- /dev/null
+++ b/app/config/telemetry.py
@@ -0,0 +1,23 @@
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class TelemetryConfig(BaseSettings):
+ """
+ OpenTelemetry switch. Everything else (service name, resource attributes,
+ sampler, export interval) comes from the standard ``OTEL_*`` variables,
+ which the SDK reads itself.
+ """
+
+ otlp_endpoint: str | None = Field(alias="OTEL_EXPORTER_OTLP_ENDPOINT", default=None)
+ sdk_disabled: bool = Field(alias="OTEL_SDK_DISABLED", default=False)
+
+ @property
+ def enabled(self) -> bool:
+ return self.otlp_endpoint is not None and not self.sdk_disabled
+
+
+TELEMETRY_CONFIG = TelemetryConfig()
+__all__ = [
+ "TELEMETRY_CONFIG",
+]
diff --git a/app/core/cache/__init__.py b/app/core/cache/__init__.py
index 9798512..e9d34ba 100644
--- a/app/core/cache/__init__.py
+++ b/app/core/cache/__init__.py
@@ -10,6 +10,7 @@
from app.config import DB_CONFIG
from app.core.log import LOGGER
+from app.core.metrics import CACHE_REQUESTS, route_template
from app.schemas.constants import INJECTED_NAMESPACE
from .base import Cache
@@ -95,15 +96,18 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return await fn(*args, **kwargs)
cache_key = _build_cache_key(request)
+ route = route_template(request)
raw = await _cache.get(cache_key)
if raw is not None:
+ CACHE_REQUESTS.add(1, {"route": route, "result": "hit"})
if response:
response.headers["X-Cache"] = "HIT"
if return_type is not None:
return _deserialize(raw, return_type)
return json.loads(raw) # type: ignore[return-value]
+ CACHE_REQUESTS.add(1, {"route": route, "result": "miss"})
if response:
response.headers["X-Cache"] = "MISS"
diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py
index b773634..250ee9e 100644
--- a/app/core/db/__init__.py
+++ b/app/core/db/__init__.py
@@ -1,14 +1,14 @@
-from .base import Base, BaseRepository
-from .redis import RedisClient
-from .session import SessionDep, close_db, get_engine, get_session, init_db
-
-__all__ = [
- "Base",
- "BaseRepository",
- "RedisClient",
- "SessionDep",
- "close_db",
- "get_engine",
- "get_session",
- "init_db",
-]
+from .base import Base, BaseRepository
+from .redis import RedisClient
+from .session import SessionDep, close_db, get_engine, get_session, init_db
+
+__all__ = [
+ "Base",
+ "BaseRepository",
+ "RedisClient",
+ "SessionDep",
+ "close_db",
+ "get_engine",
+ "get_session",
+ "init_db",
+]
diff --git a/app/core/db/base.py b/app/core/db/base.py
index 59285b1..7528cd6 100644
--- a/app/core/db/base.py
+++ b/app/core/db/base.py
@@ -1,18 +1,18 @@
-from typing import dataclass_transform
-
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import DeclarativeBase
-
-
-@dataclass_transform()
-class Base(DeclarativeBase):
- """
- Base class for all SQLAlchemy models.
- """
-
-
-class BaseRepository:
- session: AsyncSession
-
- def __init__(self, session: AsyncSession):
- self.session = session
+from typing import dataclass_transform
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import DeclarativeBase
+
+
+@dataclass_transform()
+class Base(DeclarativeBase):
+ """
+ Base class for all SQLAlchemy models.
+ """
+
+
+class BaseRepository:
+ session: AsyncSession
+
+ def __init__(self, session: AsyncSession):
+ self.session = session
diff --git a/app/core/db/models.py b/app/core/db/models.py
index 2f1dafd..52bd5ca 100644
--- a/app/core/db/models.py
+++ b/app/core/db/models.py
@@ -1,11 +1,11 @@
-"""
-This is a file for alembic to load all models for autogeneration.
-It should not be imported anywhere else.
-"""
-
-import app.core.security.model
-import app.module.beta.model
-import app.module.pool.model
-import app.module.raid.model # noqa: F401
-
-__all__ = []
+"""
+This is a file for alembic to load all models for autogeneration.
+It should not be imported anywhere else.
+"""
+
+import app.core.security.model
+import app.module.beta.model
+import app.module.pool.model
+import app.module.raid.model # noqa: F401
+
+__all__ = []
diff --git a/app/core/db/session.py b/app/core/db/session.py
index 24b3d59..b8ccee9 100644
--- a/app/core/db/session.py
+++ b/app/core/db/session.py
@@ -1,79 +1,81 @@
-from collections.abc import AsyncGenerator
-from contextlib import asynccontextmanager
-from typing import Annotated
-
-from fastapi import Depends
-from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
-
-from app.config import DB_CONFIG
-from app.core.log import LOGGER
-
-engine: AsyncEngine | None = None
-session_maker: async_sessionmaker[AsyncSession]
-
-
-def get_dsn() -> str:
- """
- Returns the database connection string based on the configuration.
- """
- if DB_CONFIG.postgres_dsn is None:
- LOGGER.warning("PostgreSQL DSN not provided, exiting application.")
- raise RuntimeError("PostgreSQL DSN is required but not provided.")
- return DB_CONFIG.postgres_dsn.encoded_string()
-
-
-async def init_db() -> AsyncEngine:
- global engine, session_maker
- dsn = get_dsn()
- engine = create_async_engine(dsn, echo=False)
- session_maker = async_sessionmaker(engine, expire_on_commit=False)
- LOGGER.debug(f"Database engine initialized with DSN: {dsn}")
- return engine
-
-
-def get_engine() -> AsyncEngine:
- """
- Returns the SQLAlchemy engine. Initializes it if not already done.
- """
- global engine
- if engine is None:
- LOGGER.error("Database engine is not initialized.")
- raise RuntimeError("Database engine is not initialized.")
- return engine
-
-
-async def close_db():
- """
- Closes the database engine connection.
- """
- global engine
- if engine is not None:
- await engine.dispose()
- LOGGER.debug("Database engine connection closed.")
-
-
-@asynccontextmanager
-async def get_session():
- async with session_maker() as session:
- try:
- LOGGER.debug("Database session started.")
- yield session
- await session.commit()
- LOGGER.debug("Database session committed.")
- except Exception:
- LOGGER.exception("Error occurred during database session, rolling back.")
- await session.rollback()
- raise
-
-
-async def get_session_fastapi() -> AsyncGenerator[AsyncSession, None]:
- """
- Returns the SQLAlchemy session maker.
- """
- async with get_session() as session:
- yield session
-
-
-SessionDep = Annotated[AsyncSession, Depends(get_session_fastapi)]
-
-__all__ = ["SessionDep", "close_db", "get_engine", "get_session", "init_db"]
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from typing import Annotated
+
+from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
+
+from app.config import DB_CONFIG
+from app.core.log import LOGGER
+from app.core.telemetry import instrument_engine
+
+engine: AsyncEngine | None = None
+session_maker: async_sessionmaker[AsyncSession]
+
+
+def get_dsn() -> str:
+ """
+ Returns the database connection string based on the configuration.
+ """
+ if DB_CONFIG.postgres_dsn is None:
+ LOGGER.warning("PostgreSQL DSN not provided, exiting application.")
+ raise RuntimeError("PostgreSQL DSN is required but not provided.")
+ return DB_CONFIG.postgres_dsn.encoded_string()
+
+
+async def init_db() -> AsyncEngine:
+ global engine, session_maker
+ dsn = get_dsn()
+ engine = create_async_engine(dsn, echo=False)
+ instrument_engine(engine)
+ session_maker = async_sessionmaker(engine, expire_on_commit=False)
+ LOGGER.debug(f"Database engine initialized for {engine.url.render_as_string(hide_password=True)}")
+ return engine
+
+
+def get_engine() -> AsyncEngine:
+ """
+ Returns the SQLAlchemy engine. Initializes it if not already done.
+ """
+ global engine
+ if engine is None:
+ LOGGER.error("Database engine is not initialized.")
+ raise RuntimeError("Database engine is not initialized.")
+ return engine
+
+
+async def close_db():
+ """
+ Closes the database engine connection.
+ """
+ global engine
+ if engine is not None:
+ await engine.dispose()
+ LOGGER.debug("Database engine connection closed.")
+
+
+@asynccontextmanager
+async def get_session():
+ async with session_maker() as session:
+ try:
+ LOGGER.debug("Database session started.")
+ yield session
+ await session.commit()
+ LOGGER.debug("Database session committed.")
+ except Exception:
+ LOGGER.exception("Error occurred during database session, rolling back.")
+ await session.rollback()
+ raise
+
+
+async def get_session_fastapi() -> AsyncGenerator[AsyncSession, None]:
+ """
+ Returns the SQLAlchemy session maker.
+ """
+ async with get_session() as session:
+ yield session
+
+
+SessionDep = Annotated[AsyncSession, Depends(get_session_fastapi)]
+
+__all__ = ["SessionDep", "close_db", "get_engine", "get_session", "init_db"]
diff --git a/app/core/log.py b/app/core/log.py
index 0b10a2c..00d0ad1 100644
--- a/app/core/log.py
+++ b/app/core/log.py
@@ -1,53 +1,110 @@
-import inspect
-import logging
-import sys
-
-import loguru
-
-from app.config import LOG_CONFIG
-
-LOGGER = loguru.logger
-
-LOGGER.remove()
-logger_id = LOGGER.add(
- sys.stdout,
- level=LOG_CONFIG.level,
- diagnose=False,
- format=LOG_CONFIG.format,
-)
-
-
-class LoguruHandler(logging.Handler):
- """Redirects logging messages to Loguru's logger."""
-
- def emit(self, record: logging.LogRecord):
- try:
- level = LOGGER.level(record.levelname).name
- except ValueError:
- level = record.levelno
-
- frame, depth = inspect.currentframe(), 0
- while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
- frame = frame.f_back
- depth += 1
-
- LOGGER.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
-
-
-def setup_logging():
- logging_root = logging.getLogger()
- logging_root.setLevel(0)
- logging_root.handlers = []
-
- logging_root.addHandler(LoguruHandler())
-
- # Remove handlers from all existing loggers to prevent duplicate logs
- for name in logging.root.manager.loggerDict.keys():
- logging.getLogger(name).handlers = []
- logging.getLogger(name).propagate = True
-
-
-setup_logging()
-
-
-__all__ = ["LOGGER"]
+import inspect
+import logging
+import sys
+import traceback
+
+import loguru
+import orjson
+from opentelemetry import trace
+
+from app.config import LOG_CONFIG
+from app.utils.redact import redact_url
+
+LOGGER = loguru.logger
+
+
+def _add_trace_context(record: "loguru.Record") -> None:
+ """Attach the active span, so Grafana can jump from a trace to its log lines."""
+ ctx = trace.get_current_span().get_span_context()
+ if ctx.is_valid:
+ trace_id, span_id = trace.format_trace_id(ctx.trace_id), trace.format_span_id(ctx.span_id)
+ record["extra"].update(trace_id=trace_id, span_id=span_id, trace=f" trace_id={trace_id} span_id={span_id}")
+ else:
+ record["extra"]["trace"] = ""
+
+
+def _json_sink(message: "loguru.Message") -> None:
+ record = message.record
+ entry: dict[str, object] = {
+ "time": record["time"].isoformat(),
+ "level": record["level"].name,
+ "logger": record["name"],
+ "function": record["function"],
+ "line": record["line"],
+ "message": record["message"],
+ }
+ # trace_id / span_id and anything bound with LOGGER.bind(); "trace" is the text-format suffix.
+ entry.update((k, v) for k, v in record["extra"].items() if k != "trace")
+ if exc := record["exception"]:
+ entry["exception"] = "".join(traceback.format_exception(exc.type, exc.value, exc.traceback))
+ sys.stdout.write(orjson.dumps(entry, default=str).decode() + "\n")
+ sys.stdout.flush()
+
+
+LOGGER.remove()
+LOGGER.configure(patcher=_add_trace_context)
+if LOG_CONFIG.json_output:
+ logger_id = LOGGER.add(_json_sink, level=LOG_CONFIG.level, diagnose=False)
+else:
+ logger_id = LOGGER.add(sys.stdout, level=LOG_CONFIG.level, diagnose=False, format=LOG_CONFIG.format)
+
+
+def _access_fields(record: logging.LogRecord) -> dict[str, object] | None:
+ """
+ Fields of a uvicorn access record, logged as ``'%s - "%s %s HTTP/%s" %d'``
+ with args (client, method, target, version, status).
+ """
+ if record.name != "uvicorn.access" or not isinstance(record.args, tuple) or len(record.args) != 5:
+ return None
+ client, method, target, version, status = record.args
+ return {
+ "client_address": client,
+ "http_method": method,
+ # Same rule as the spans: manage endpoints carry user tokens in the query.
+ "http_target": redact_url(str(target)),
+ "http_version": version,
+ "status_code": status,
+ }
+
+
+class LoguruHandler(logging.Handler):
+ """Redirects logging messages to Loguru's logger."""
+
+ def emit(self, record: logging.LogRecord):
+ try:
+ level = LOGGER.level(record.levelname).name
+ except ValueError:
+ level = record.levelno
+
+ frame, depth = inspect.currentframe(), 0
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
+ frame = frame.f_back
+ depth += 1
+
+ logger, message = LOGGER, None
+ if fields := _access_fields(record):
+ logger = LOGGER.bind(**fields)
+ message = (
+ f'{fields["client_address"]} - "{fields["http_method"]} {fields["http_target"]} '
+ f'HTTP/{fields["http_version"]}" {fields["status_code"]}'
+ )
+ logger.opt(depth=depth, exception=record.exc_info).log(level, message or record.getMessage())
+
+
+def setup_logging():
+ logging_root = logging.getLogger()
+ logging_root.setLevel(0)
+ logging_root.handlers = []
+
+ logging_root.addHandler(LoguruHandler())
+
+ # Remove handlers from all existing loggers to prevent duplicate logs
+ for name in logging.root.manager.loggerDict.keys():
+ logging.getLogger(name).handlers = []
+ logging.getLogger(name).propagate = True
+
+
+setup_logging()
+
+
+__all__ = ["LOGGER"]
diff --git a/app/core/metadata.py b/app/core/metadata.py
index e353bb9..028da15 100644
--- a/app/core/metadata.py
+++ b/app/core/metadata.py
@@ -1,100 +1,100 @@
-import dataclasses
-from collections.abc import Awaitable, Callable
-
-from app.core.rate_limiter import RateLimitKeyFunc, ip_based_key_func
-
-
-@dataclasses.dataclass
-class CacheConfig:
- expire: int = 60
-
-
-@dataclasses.dataclass
-class RateLimitMetadata:
- limit: int
- period: int
- key_func: RateLimitKeyFunc
-
-
-@dataclasses.dataclass
-class EndpointMetadata:
- """
- Metadata for an API endpoint.
- """
-
- # Indicates whether the metadata has been processed by the router.
- processed: bool = False
-
- permission: str | set[str] | None = None
- cache: CacheConfig | None = None
- rate_limit: RateLimitMetadata | None = None
-
-
-def cached(expire: int = 60):
- """
- Decorator to add cache metadata to an API endpoint.
- """
-
- def decorator(func: Callable[..., Awaitable]):
- meta = getattr(func, "__metadata__", None)
- if meta is not None:
- if not isinstance(meta, EndpointMetadata):
- raise ValueError(
- f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
- )
- meta = dataclasses.replace(meta, cache=CacheConfig(expire=expire))
- else:
- meta = EndpointMetadata(cache=CacheConfig(expire=expire))
- setattr(func, "__metadata__", meta)
- return func
-
- return decorator
-
-
-def permission(permission: str | set[str] | None = None):
- """
- Decorator to add metadata to an API endpoint.
- """
-
- def decorator(func):
- meta = getattr(func, "__metadata__", None)
- if meta is not None:
- if not isinstance(meta, EndpointMetadata):
- raise ValueError(
- f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
- )
- meta = dataclasses.replace(meta, permission=permission)
- else:
- meta = EndpointMetadata(permission=permission)
- setattr(func, "__metadata__", meta)
- return func
-
- return decorator
-
-
-def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func):
- """
- Decorator to add rate limit metadata to an API endpoint.
- """
-
- def decorator(func):
- meta = getattr(func, "__metadata__", None)
- if meta is not None:
- if not isinstance(meta, EndpointMetadata):
- raise ValueError(
- f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
- )
- meta = dataclasses.replace(
- meta, rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func)
- )
- else:
- meta = EndpointMetadata(rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func))
- setattr(func, "__metadata__", meta)
- return func
-
- return decorator
-
-
-# metadata = MetadataBuilder()
-
-__all__ = ["EndpointMetadata", "cached", "permission", "rate_limit"]
+import dataclasses
+from collections.abc import Awaitable, Callable
+
+from app.core.rate_limiter import RateLimitKeyFunc, ip_based_key_func
+
+
+@dataclasses.dataclass
+class CacheConfig:
+ expire: int = 60
+
+
+@dataclasses.dataclass
+class RateLimitMetadata:
+ limit: int
+ period: int
+ key_func: RateLimitKeyFunc
+
+
+@dataclasses.dataclass
+class EndpointMetadata:
+ """
+ Metadata for an API endpoint.
+ """
+
+ # Indicates whether the metadata has been processed by the router.
+ processed: bool = False
+
+ permission: str | set[str] | None = None
+ cache: CacheConfig | None = None
+ rate_limit: RateLimitMetadata | None = None
+
+
+def cached(expire: int = 60):
+ """
+ Decorator to add cache metadata to an API endpoint.
+ """
+
+ def decorator(func: Callable[..., Awaitable]):
+ meta = getattr(func, "__metadata__", None)
+ if meta is not None:
+ if not isinstance(meta, EndpointMetadata):
+ raise ValueError(
+ f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
+ )
+ meta = dataclasses.replace(meta, cache=CacheConfig(expire=expire))
+ else:
+ meta = EndpointMetadata(cache=CacheConfig(expire=expire))
+ setattr(func, "__metadata__", meta)
+ return func
+
+ return decorator
+
+
+def permission(permission: str | set[str] | None = None):
+ """
+ Decorator to add metadata to an API endpoint.
+ """
+
+ def decorator(func):
+ meta = getattr(func, "__metadata__", None)
+ if meta is not None:
+ if not isinstance(meta, EndpointMetadata):
+ raise ValueError(
+ f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
+ )
+ meta = dataclasses.replace(meta, permission=permission)
+ else:
+ meta = EndpointMetadata(permission=permission)
+ setattr(func, "__metadata__", meta)
+ return func
+
+ return decorator
+
+
+def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func):
+ """
+ Decorator to add rate limit metadata to an API endpoint.
+ """
+
+ def decorator(func):
+ meta = getattr(func, "__metadata__", None)
+ if meta is not None:
+ if not isinstance(meta, EndpointMetadata):
+ raise ValueError(
+ f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance"
+ )
+ meta = dataclasses.replace(
+ meta, rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func)
+ )
+ else:
+ meta = EndpointMetadata(rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func))
+ setattr(func, "__metadata__", meta)
+ return func
+
+ return decorator
+
+
+# metadata = MetadataBuilder()
+
+__all__ = ["EndpointMetadata", "cached", "permission", "rate_limit"]
diff --git a/app/core/metrics.py b/app/core/metrics.py
new file mode 100644
index 0000000..3636ce3
--- /dev/null
+++ b/app/core/metrics.py
@@ -0,0 +1,127 @@
+"""
+Application metrics. Instruments are created against the global meter, which
+forwards to the real provider once ``setup_telemetry`` has run and is a no-op
+otherwise.
+
+Attribute values must stay low-cardinality: route templates, enum values and
+fixed job names only; never user ids, IPs or tokens.
+"""
+
+import time
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+
+from opentelemetry import metrics
+from starlette.requests import Request
+
+from app.core.telemetry import TRACER
+
+_meter = metrics.get_meter("wynnsource")
+
+_SECONDS_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120]
+_COUNT_BUCKETS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
+
+SUBMISSIONS = _meter.create_counter(
+ "wcs.submissions",
+ unit="{submission}",
+ description="Submitted entries by module and outcome (accepted, rejected, unchanged).",
+)
+SUBMISSION_ITEMS = _meter.create_histogram(
+ "wcs.submission.items",
+ unit="{item}",
+ description="Entries per submission request.",
+ explicit_bucket_boundaries_advisory=_COUNT_BUCKETS,
+)
+
+CONSENSUS_RUNS = _meter.create_counter(
+ "wcs.consensus.runs",
+ unit="{run}",
+ description="Consensus computations by kind and status.",
+)
+CONSENSUS_DURATION = _meter.create_histogram(
+ "wcs.consensus.duration",
+ unit="s",
+ description="Consensus computation time.",
+ explicit_bucket_boundaries_advisory=_SECONDS_BUCKETS,
+)
+CONSENSUS_PENDING = _meter.create_gauge(
+ "wcs.consensus.pending",
+ unit="{row}",
+ description="Rows flagged needs_recalc when a consensus run started.",
+)
+
+JOB_RUNS = _meter.create_counter(
+ "wcs.job.runs",
+ unit="{run}",
+ description="Scheduler job executions by status (ok, error, skipped = another replica holds the lock).",
+)
+JOB_DURATION = _meter.create_histogram(
+ "wcs.job.duration",
+ unit="s",
+ description="Scheduler job execution time.",
+ explicit_bucket_boundaries_advisory=_SECONDS_BUCKETS,
+)
+JOB_LAST_SUCCESS = _meter.create_gauge(
+ "wcs.job.last_success",
+ unit="s",
+ description="Unix time of the last successful run of a scheduler job.",
+)
+
+CACHE_REQUESTS = _meter.create_counter(
+ "wcs.cache.requests",
+ unit="{request}",
+ description="Response cache lookups by route and result (hit, miss).",
+)
+RATE_LIMITED = _meter.create_counter(
+ "wcs.rate_limited",
+ unit="{request}",
+ description="Requests rejected by the rate limiter, by route and key type (ip, user).",
+)
+
+
+def route_template(request: Request) -> str:
+ """The matched route's path template, e.g. ``/api/v2/pool/pools/{pool_type}/{region}``."""
+ route = request.scope.get("route")
+ return getattr(route, "path", None) or "unmatched"
+
+
+@contextmanager
+def consensus_run(kind: str, attributes: dict[str, str] | None = None) -> Iterator[Callable[[int], int]]:
+ """
+ Span ``consensus `` plus run/duration metrics around a consensus
+ computation. Yields ``record(n)``, to be called with the number of rows
+ that were flagged for recalculation; it returns ``n``.
+ """
+ attrs = {"kind": kind, **(attributes or {})}
+ start = time.perf_counter()
+ status = "error"
+ span_attributes = {f"wcs.{k}": v for k, v in attrs.items()}
+ with TRACER.start_as_current_span(f"consensus {kind}", attributes=span_attributes) as span:
+
+ def record(rows: int) -> int:
+ span.set_attribute("wcs.rows", rows)
+ CONSENSUS_PENDING.set(rows, attrs)
+ return rows
+
+ try:
+ yield record
+ status = "ok"
+ finally:
+ CONSENSUS_RUNS.add(1, {**attrs, "status": status})
+ CONSENSUS_DURATION.record(time.perf_counter() - start, attrs)
+
+
+__all__ = [
+ "CACHE_REQUESTS",
+ "CONSENSUS_DURATION",
+ "CONSENSUS_PENDING",
+ "CONSENSUS_RUNS",
+ "JOB_DURATION",
+ "JOB_LAST_SUCCESS",
+ "JOB_RUNS",
+ "RATE_LIMITED",
+ "SUBMISSIONS",
+ "SUBMISSION_ITEMS",
+ "consensus_run",
+ "route_template",
+]
diff --git a/app/core/openapi.py b/app/core/openapi.py
index 6cd08d9..daa757c 100644
--- a/app/core/openapi.py
+++ b/app/core/openapi.py
@@ -1,80 +1,80 @@
-from fastapi import FastAPI
-from fastapi.openapi.utils import get_openapi
-
-from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__
-from app.schemas.enums import ApiTag
-
-
-def custom_openapi(app: FastAPI):
- def openapi():
- nonlocal app
-
- if app.openapi_schema:
- return app.openapi_schema
-
- openapi_schema = get_openapi(
- title=__NAME__,
- version=__VERSION__,
- summary=f"{__NAME__} API Documentation",
- description=__DESCRIPTION__,
- routes=app.routes,
- )
-
- openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": [t.value for t in ApiTag]}]
-
- app.openapi_schema = openapi_schema
- return app.openapi_schema
-
- return openapi
-
-
-RATE_LIMIT_DOCS = {
- 200: {
- "headers": {
- "X-RateLimit-Limit": {
- "schema": {"type": "integer"},
- "description": "Requests allowed per window",
- },
- "X-RateLimit-Remaining": {
- "schema": {"type": "integer"},
- "description": "Requests remaining",
- },
- "X-RateLimit-Reset": {
- "schema": {"type": "integer"},
- "description": "Seconds until reset",
- },
- },
- },
- 429: {
- "description": "Too Many Requests",
- "headers": {
- "X-RateLimit-Limit": {
- "schema": {"type": "integer"},
- "description": "Requests allowed per window",
- },
- "X-RateLimit-Remaining": {
- "schema": {"type": "integer"},
- "description": "Requests remaining",
- },
- "X-RateLimit-Reset": {
- "schema": {"type": "integer"},
- "description": "Seconds until reset",
- },
- "X-Retry-After": {
- "schema": {"type": "integer"},
- "description": "Seconds until you can retry",
- },
- },
- },
-}
-
-CACHE_DOCS = {
- 200: {
- "headers": {
- "X-Cache": {
- "schema": {"type": "string", "enum": ["HIT", "MISS"]},
- "description": "Cache status",
- },
- }
- }
-}
+from fastapi import FastAPI
+from fastapi.openapi.utils import get_openapi
+
+from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__
+from app.schemas.enums import ApiTag
+
+
+def custom_openapi(app: FastAPI):
+ def openapi():
+ nonlocal app
+
+ if app.openapi_schema:
+ return app.openapi_schema
+
+ openapi_schema = get_openapi(
+ title=__NAME__,
+ version=__VERSION__,
+ summary=f"{__NAME__} API Documentation",
+ description=__DESCRIPTION__,
+ routes=app.routes,
+ )
+
+ openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": [t.value for t in ApiTag]}]
+
+ app.openapi_schema = openapi_schema
+ return app.openapi_schema
+
+ return openapi
+
+
+RATE_LIMIT_DOCS = {
+ 200: {
+ "headers": {
+ "X-RateLimit-Limit": {
+ "schema": {"type": "integer"},
+ "description": "Requests allowed per window",
+ },
+ "X-RateLimit-Remaining": {
+ "schema": {"type": "integer"},
+ "description": "Requests remaining",
+ },
+ "X-RateLimit-Reset": {
+ "schema": {"type": "integer"},
+ "description": "Seconds until reset",
+ },
+ },
+ },
+ 429: {
+ "description": "Too Many Requests",
+ "headers": {
+ "X-RateLimit-Limit": {
+ "schema": {"type": "integer"},
+ "description": "Requests allowed per window",
+ },
+ "X-RateLimit-Remaining": {
+ "schema": {"type": "integer"},
+ "description": "Requests remaining",
+ },
+ "X-RateLimit-Reset": {
+ "schema": {"type": "integer"},
+ "description": "Seconds until reset",
+ },
+ "X-Retry-After": {
+ "schema": {"type": "integer"},
+ "description": "Seconds until you can retry",
+ },
+ },
+ },
+}
+
+CACHE_DOCS = {
+ 200: {
+ "headers": {
+ "X-Cache": {
+ "schema": {"type": "string", "enum": ["HIT", "MISS"]},
+ "description": "Cache status",
+ },
+ }
+ }
+}
diff --git a/app/core/rate_limiter/base.py b/app/core/rate_limiter/base.py
index b97ba28..ae3d3f5 100644
--- a/app/core/rate_limiter/base.py
+++ b/app/core/rate_limiter/base.py
@@ -3,6 +3,8 @@
from fastapi import Request, Response
+from app.core.metrics import RATE_LIMITED, route_template
+
type RateLimitKeyFunc = Callable[[Request], str]
@@ -38,6 +40,12 @@ def __init__(self, limit: int, period: int, key_func: RateLimitKeyFunc = ip_base
self.period = period
self.key_func = key_func
+ def record_rejection(self, request: Request) -> None:
+ RATE_LIMITED.add(
+ 1,
+ {"route": route_template(request), "key": "user" if self.key_func is user_based_key_func else "ip"},
+ )
+
@abc.abstractmethod
async def __call__(self, request: Request, response: Response) -> Response:
pass
diff --git a/app/core/rate_limiter/memory_rate_limiter.py b/app/core/rate_limiter/memory_rate_limiter.py
index 30aff2e..4259cca 100644
--- a/app/core/rate_limiter/memory_rate_limiter.py
+++ b/app/core/rate_limiter/memory_rate_limiter.py
@@ -46,6 +46,7 @@ async def __call__(self, request: Request, response: Response) -> Response:
response.headers["X-RateLimit-Reset"] = str(math.ceil(reset_after))
if estimated >= self.limit:
+ self.record_rejection(request)
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["Retry-After"] = str(math.ceil(reset_after))
raise HTTPException(
diff --git a/app/core/rate_limiter/redis_rate_limiter.py b/app/core/rate_limiter/redis_rate_limiter.py
index 2881c65..31e42b2 100644
--- a/app/core/rate_limiter/redis_rate_limiter.py
+++ b/app/core/rate_limiter/redis_rate_limiter.py
@@ -74,6 +74,7 @@ async def __call__(self, request: Request, response: Response) -> Response:
response.headers["X-RateLimit-Reset"] = str(reset_after)
if remaining < 0:
+ self.record_rejection(request)
response.headers["X-RateLimit-Remaining"] = "0"
response.headers["Retry-After"] = str(reset_after)
raise HTTPException(
diff --git a/app/core/scheduler.py b/app/core/scheduler.py
index ab47c1a..e682b32 100644
--- a/app/core/scheduler.py
+++ b/app/core/scheduler.py
@@ -1,17 +1,112 @@
-from apscheduler.events import EVENT_JOB_ERROR, JobExecutionEvent
+import datetime
+import os
+import socket
+import time
+from collections.abc import Awaitable, Callable
+from functools import wraps
+from typing import Any
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.base import BaseTrigger
+from opentelemetry.instrumentation.utils import suppress_instrumentation
+from opentelemetry.trace import Status, StatusCode
+from app.core.db.redis import RedisClient
+from app.core.log import LOGGER
+from app.core.metrics import JOB_DURATION, JOB_LAST_SUCCESS, JOB_RUNS
from app.core.sentry import sentry_enabled
+from app.core.telemetry import TRACER
SCHEDULER = AsyncIOScheduler()
-if sentry_enabled():
- import sentry_sdk as sentry
+# Identifies the lock holder; the pod name in Kubernetes.
+_HOLDER = os.environ.get("K8S_POD_NAME") or socket.gethostname()
+
+
+async def _acquire_lock(key: str, ttl: datetime.timedelta) -> bool:
+ """
+ Claim ``key`` for ``ttl`` across replicas. The lock is never released early:
+ interval triggers are anchored to each replica's start time, so their fire
+ times are offset; releasing after the run would let the other replica run
+ the same period again. A TTL just under the period gives one run per period.
+ Fails open (runs the job) if Redis is unreachable.
+ """
+ try:
+ # The lock is bookkeeping, not part of the job's trace.
+ with suppress_instrumentation():
+ acquired = await RedisClient.get_instance().set(
+ f"wcs:job-lock:{key}", _HOLDER, nx=True, px=int(ttl.total_seconds() * 1000)
+ )
+ except Exception as e:
+ LOGGER.warning(f"Job lock {key} unavailable, running without it: {e}")
+ return True
+ return bool(acquired)
+
+
+def instrument_job[**P, R](
+ job: str,
+ fn: Callable[P, Awaitable[R]],
+ *,
+ lock_ttl: datetime.timedelta | None = None,
+ lock_key: str | None = None,
+) -> Callable[P, Awaitable[R | None]]:
+ """
+ Wrap a scheduler job: optional cross-replica lock, a root span ``job ``,
+ run/duration/last-success metrics, and Sentry capture of failures.
+
+ Jobs that only touch process-local state (in-memory caches, registering
+ local jobs) must run on every replica and take no lock.
+ """
+ attributes = {"job": job}
+
+ @wraps(fn)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
+ if lock_ttl is not None and not await _acquire_lock(lock_key or job, lock_ttl):
+ JOB_RUNS.add(1, {**attributes, "status": "skipped"})
+ return None
+
+ start = time.perf_counter()
+ with TRACER.start_as_current_span(
+ f"job {job}", attributes={"wcs.job": job}, record_exception=False, set_status_on_exception=False
+ ) as span:
+ try:
+ result = await fn(*args, **kwargs)
+ except Exception as e:
+ span.record_exception(e)
+ span.set_status(Status(StatusCode.ERROR, type(e).__name__))
+ JOB_RUNS.add(1, {**attributes, "status": "error"})
+ if sentry_enabled():
+ import sentry_sdk as sentry
+
+ sentry.capture_exception(e)
+ raise
+ finally:
+ JOB_DURATION.record(time.perf_counter() - start, attributes)
+
+ JOB_RUNS.add(1, {**attributes, "status": "ok"})
+ JOB_LAST_SUCCESS.set(time.time(), attributes)
+ return result
+
+ return wrapper
+
+
+def scheduled_job[**P, R](
+ trigger: BaseTrigger,
+ *,
+ id: str,
+ lock_ttl: datetime.timedelta | None = None,
+ **job_kwargs: Any,
+) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
+ """
+ Register an instrumented job and return the original function unchanged,
+ so direct callers (e.g. the manual recalc endpoints) bypass the lock.
+ """
- def sentry_listener(event: JobExecutionEvent):
- sentry.capture_exception(event.exception)
+ def decorator(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
+ SCHEDULER.add_job(instrument_job(id, fn, lock_ttl=lock_ttl), trigger, id=id, **job_kwargs)
+ return fn
- SCHEDULER.add_listener(sentry_listener, EVENT_JOB_ERROR)
+ return decorator
-__all__ = ["SCHEDULER"]
+__all__ = ["SCHEDULER", "instrument_job", "scheduled_job"]
diff --git a/app/core/score.py b/app/core/score.py
index 9190def..fa94c7d 100644
--- a/app/core/score.py
+++ b/app/core/score.py
@@ -5,8 +5,9 @@
from typing import TYPE_CHECKING
from apscheduler.triggers.cron import CronTrigger
+from opentelemetry import trace
-from app.core.scheduler import SCHEDULER
+from app.core.scheduler import scheduled_job
if TYPE_CHECKING:
from app.module.pool.model import Pool
@@ -97,9 +98,10 @@ def score_to_next_tier(self, score: int) -> int:
return next_tier.score_range.min - score
-@SCHEDULER.scheduled_job(
+@scheduled_job(
CronTrigger(hour=0, minute=0), # Run daily at midnight
id="update_user_scores",
+ lock_ttl=datetime.timedelta(minutes=30),
misfire_grace_time=60,
coalesce=True,
)
@@ -160,6 +162,7 @@ async def update_user_scores():
f"User {user.id}: quality={quality:.3f}, activity={activity:.3f}, delta={delta}, new_score={new_score}"
)
+ trace.get_current_span().set_attributes({"wcs.pools": len(all_current_pools), "wcs.users": len(users)})
LOGGER.info(f"Updated scores for {len(users)} users")
diff --git a/app/core/security/auth.py b/app/core/security/auth.py
index 2be9ec2..3ccd8d3 100644
--- a/app/core/security/auth.py
+++ b/app/core/security/auth.py
@@ -1,134 +1,134 @@
-import re
-from datetime import UTC, datetime
-from functools import lru_cache
-from hashlib import sha256
-from typing import Annotated
-
-from fastapi import Depends, Header, HTTPException, Request, Security
-from fastapi.security import APIKeyHeader
-from starlette.status import HTTP_401_UNAUTHORIZED
-
-from app.config import ADMIN_CONFIG
-from app.core.db import SessionDep
-from app.core.log import LOGGER
-
-from .model import User, UserRepository
-
-
-@lru_cache(maxsize=256)
-def _compile_pattern(pattern: str) -> re.Pattern:
- """
- Compile user permission pattern (with optional *) into a regex pattern.
- """
- escaped = re.escape(pattern).replace(r"\*", ".*")
- return re.compile(f"^{escaped}$")
-
-
-def has_permission(required: str | set[str] | None, user: set[str]) -> bool:
- """
- Check if the user's permissions satisfy the required permissions.
- Only user permissions can contain wildcards (*).
- """
- if required is None:
- return True
-
- if isinstance(required, str):
- required = {required}
-
- for req in required:
- # Check for exact match or wildcard match in user permissions
- if not any(
- _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req
- for user_perm in user
- ):
- return False
- return True
-
-
-async def verify_user(user: User):
- if user.expires_at and user.expires_at < datetime.now(UTC):
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired")
-
-
-api_key_scheme = APIKeyHeader(name="X-API-KEY", auto_error=False)
-
-
-async def get_user(
- request: Request,
- session: SessionDep,
- api_key: Annotated[str | None, Security(api_key_scheme)] = None,
- x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None,
-) -> User:
- if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None:
- # Create a dummy token with all permissions for the admin token
- LOGGER.info("Admin token used, granting all permissions")
- return User(
- id=-1,
- token="",
- permissions=["*"],
- created_at=datetime.now(UTC),
- expires_at=datetime.now(UTC),
- is_active=True,
- creation_ip="unknown",
- common_ips=[],
- score=0,
- )
- else:
- if api_key is None:
- raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="missing API key")
-
- userRepo = UserRepository(session)
- api_key = hash_token(api_key)
- user = await userRepo.get_user_by_token(api_key)
-
- if not user:
- raise HTTPException(
- status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token"
- )
-
- assert request.client is not None
-
- if request.client.host == "127.0.0.1":
- LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header")
- elif x_real_ip is None:
- LOGGER.warning(f"Token {user.token} used without X-Real-IP header")
- raise HTTPException(
- status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address"
- )
-
- await userRepo.update_user_ip(user, x_real_ip or request.client.host)
-
- await verify_user(user)
-
- request.state.user_id = (
- user.id
- ) # Store user ID in request state for later use (e.g. rate limiting)
- return user
-
-
-UserDep = Annotated[User, Depends(get_user)]
-IpDep = Annotated[str, Header(alias="X-Real-IP", include_in_schema=False)]
-
-
-def depends_permission(permission: str | set[str]):
- async def dependency(user: UserDep) -> User:
- if not has_permission(permission, set(user.permissions)):
- LOGGER.debug(f"User {user.token} does not have required permissions: {permission}")
- LOGGER.debug(f"User {user.token} permissions: {user.permissions}")
- raise HTTPException(status_code=403, detail="Insufficient permissions")
-
- return user
-
- return dependency
-
-
-@lru_cache(maxsize=1024)
-def hash_token(token_str: str) -> str:
- return sha256(token_str.encode()).hexdigest()
-
-
-def hash_tokens(token_strs: list[str]) -> list[str]:
- return [hash_token(t) for t in token_strs]
-
-
-__all__ = ["UserDep", "depends_permission", "get_user", "hash_token", "hash_tokens"]
+import re
+from datetime import UTC, datetime
+from functools import lru_cache
+from hashlib import sha256
+from typing import Annotated
+
+from fastapi import Depends, Header, HTTPException, Request, Security
+from fastapi.security import APIKeyHeader
+from starlette.status import HTTP_401_UNAUTHORIZED
+
+from app.config import ADMIN_CONFIG
+from app.core.db import SessionDep
+from app.core.log import LOGGER
+
+from .model import User, UserRepository
+
+
+@lru_cache(maxsize=256)
+def _compile_pattern(pattern: str) -> re.Pattern:
+ """
+ Compile user permission pattern (with optional *) into a regex pattern.
+ """
+ escaped = re.escape(pattern).replace(r"\*", ".*")
+ return re.compile(f"^{escaped}$")
+
+
+def has_permission(required: str | set[str] | None, user: set[str]) -> bool:
+ """
+ Check if the user's permissions satisfy the required permissions.
+ Only user permissions can contain wildcards (*).
+ """
+ if required is None:
+ return True
+
+ if isinstance(required, str):
+ required = {required}
+
+ for req in required:
+ # Check for exact match or wildcard match in user permissions
+ if not any(
+ _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req
+ for user_perm in user
+ ):
+ return False
+ return True
+
+
+async def verify_user(user: User):
+ if user.expires_at and user.expires_at < datetime.now(UTC):
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired")
+
+
+api_key_scheme = APIKeyHeader(name="X-API-KEY", auto_error=False)
+
+
+async def get_user(
+ request: Request,
+ session: SessionDep,
+ api_key: Annotated[str | None, Security(api_key_scheme)] = None,
+ x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None,
+) -> User:
+ if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None:
+ # Create a dummy token with all permissions for the admin token
+ LOGGER.info("Admin token used, granting all permissions")
+ return User(
+ id=-1,
+ token="",
+ permissions=["*"],
+ created_at=datetime.now(UTC),
+ expires_at=datetime.now(UTC),
+ is_active=True,
+ creation_ip="unknown",
+ common_ips=[],
+ score=0,
+ )
+ else:
+ if api_key is None:
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="missing API key")
+
+ userRepo = UserRepository(session)
+ api_key = hash_token(api_key)
+ user = await userRepo.get_user_by_token(api_key)
+
+ if not user:
+ raise HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token"
+ )
+
+ assert request.client is not None
+
+ if request.client.host == "127.0.0.1":
+ LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header")
+ elif x_real_ip is None:
+ LOGGER.warning(f"Token {user.token} used without X-Real-IP header")
+ raise HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address"
+ )
+
+ await userRepo.update_user_ip(user, x_real_ip or request.client.host)
+
+ await verify_user(user)
+
+ request.state.user_id = (
+ user.id
+ ) # Store user ID in request state for later use (e.g. rate limiting)
+ return user
+
+
+UserDep = Annotated[User, Depends(get_user)]
+IpDep = Annotated[str, Header(alias="X-Real-IP", include_in_schema=False)]
+
+
+def depends_permission(permission: str | set[str]):
+ async def dependency(user: UserDep) -> User:
+ if not has_permission(permission, set(user.permissions)):
+ LOGGER.debug(f"User {user.token} does not have required permissions: {permission}")
+ LOGGER.debug(f"User {user.token} permissions: {user.permissions}")
+ raise HTTPException(status_code=403, detail="Insufficient permissions")
+
+ return user
+
+ return dependency
+
+
+@lru_cache(maxsize=1024)
+def hash_token(token_str: str) -> str:
+ return sha256(token_str.encode()).hexdigest()
+
+
+def hash_tokens(token_strs: list[str]) -> list[str]:
+ return [hash_token(t) for t in token_strs]
+
+
+__all__ = ["UserDep", "depends_permission", "get_user", "hash_token", "hash_tokens"]
diff --git a/app/core/security/model.py b/app/core/security/model.py
index ad78cbe..be82c5a 100644
--- a/app/core/security/model.py
+++ b/app/core/security/model.py
@@ -1,79 +1,79 @@
-from datetime import datetime
-
-from sqlalchemy import DateTime, String, delete, func, select
-from sqlalchemy.orm import (
- Mapped,
- mapped_column,
-)
-from sqlalchemy.types import ARRAY
-
-from app.config import USER_CONFIG
-from app.core.db import Base, BaseRepository
-
-
-class User(Base):
- __tablename__ = "users"
-
- id: Mapped[int] = mapped_column(primary_key=True)
-
- token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True)
- permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[])
-
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
- expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
- is_active: Mapped[bool] = mapped_column(default=True)
-
- creation_ip: Mapped[str] = mapped_column(String, nullable=False)
- # A list of common IPs used by this user, ordered from oldest to newest.
- common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[])
-
- score: Mapped[int] = mapped_column(default=0)
-
-
-class UserRepository(BaseRepository):
- async def get_user_by_token(self, token_str: str, include_inactive: bool = False) -> User | None:
- query = select(User).where(User.token == token_str)
- if not include_inactive:
- query = query.where(User.is_active == True) # noqa: E712
- result = await self.session.execute(query)
- return result.scalars().first()
-
- async def get_users_by_tokens(self, token_strs: list[str], include_inactive: bool = False) -> list[User]:
- query = select(User).where(User.token.in_(token_strs))
- if not include_inactive:
- query = query.where(User.is_active == True) # noqa: E712
- result = await self.session.execute(query)
- return list(result.scalars().all())
-
- async def list_users(self, include_inactive: bool = False) -> list[User]:
- query = select(User)
- if not include_inactive:
- query = query.where(User.is_active == True) # noqa: E712
- result = await self.session.execute(query)
- return list(result.scalars().all())
-
- async def save(self, user: User) -> None:
- self.session.add(user)
- await self.session.flush()
- await self.session.refresh(user, attribute_names=["token"])
-
- async def delete(self, token_strs: list[str]) -> None:
- await self.session.execute(delete(User).where(User.token.in_(token_strs)))
- await self.session.flush()
-
- async def get_users_by_ids(self, user_ids: list[int]) -> list[User]:
- if not user_ids:
- return []
- query = select(User).where(User.id.in_(user_ids))
- result = await self.session.execute(query)
- return list(result.scalars().all())
-
- async def update_user_ip(self, user: User, ip: str) -> None:
- if ip not in user.common_ips:
- if len(user.common_ips) >= USER_CONFIG.max_ip_records:
- user.common_ips.pop(0)
- user.common_ips.append(ip)
- else:
- user.common_ips.remove(ip)
- user.common_ips.append(ip)
- await self.session.flush()
+from datetime import datetime
+
+from sqlalchemy import DateTime, String, delete, func, select
+from sqlalchemy.orm import (
+ Mapped,
+ mapped_column,
+)
+from sqlalchemy.types import ARRAY
+
+from app.config import USER_CONFIG
+from app.core.db import Base, BaseRepository
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+ token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True)
+ permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[])
+
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ is_active: Mapped[bool] = mapped_column(default=True)
+
+ creation_ip: Mapped[str] = mapped_column(String, nullable=False)
+ # A list of common IPs used by this user, ordered from oldest to newest.
+ common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[])
+
+ score: Mapped[int] = mapped_column(default=0)
+
+
+class UserRepository(BaseRepository):
+ async def get_user_by_token(self, token_str: str, include_inactive: bool = False) -> User | None:
+ query = select(User).where(User.token == token_str)
+ if not include_inactive:
+ query = query.where(User.is_active == True) # noqa: E712
+ result = await self.session.execute(query)
+ return result.scalars().first()
+
+ async def get_users_by_tokens(self, token_strs: list[str], include_inactive: bool = False) -> list[User]:
+ query = select(User).where(User.token.in_(token_strs))
+ if not include_inactive:
+ query = query.where(User.is_active == True) # noqa: E712
+ result = await self.session.execute(query)
+ return list(result.scalars().all())
+
+ async def list_users(self, include_inactive: bool = False) -> list[User]:
+ query = select(User)
+ if not include_inactive:
+ query = query.where(User.is_active == True) # noqa: E712
+ result = await self.session.execute(query)
+ return list(result.scalars().all())
+
+ async def save(self, user: User) -> None:
+ self.session.add(user)
+ await self.session.flush()
+ await self.session.refresh(user, attribute_names=["token"])
+
+ async def delete(self, token_strs: list[str]) -> None:
+ await self.session.execute(delete(User).where(User.token.in_(token_strs)))
+ await self.session.flush()
+
+ async def get_users_by_ids(self, user_ids: list[int]) -> list[User]:
+ if not user_ids:
+ return []
+ query = select(User).where(User.id.in_(user_ids))
+ result = await self.session.execute(query)
+ return list(result.scalars().all())
+
+ async def update_user_ip(self, user: User, ip: str) -> None:
+ if ip not in user.common_ips:
+ if len(user.common_ips) >= USER_CONFIG.max_ip_records:
+ user.common_ips.pop(0)
+ user.common_ips.append(ip)
+ else:
+ user.common_ips.remove(ip)
+ user.common_ips.append(ip)
+ await self.session.flush()
diff --git a/app/core/sentry.py b/app/core/sentry.py
index dbfb8dd..5f5aa86 100644
--- a/app/core/sentry.py
+++ b/app/core/sentry.py
@@ -1,10 +1,12 @@
import logging
import sentry_sdk as sentry
+import sentry_sdk.profiler as sentry_profiler
from fastapi import HTTPException
from sentry_sdk.integrations.asyncio import AsyncioIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.loguru import LoguruIntegration
+from sentry_sdk.integrations.otlp import OTLPIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.types import Event, Hint
@@ -34,8 +36,10 @@ def init_sentry() -> bool:
dsn=SENTRY_CONFIG.dsn,
environment=SENTRY_CONFIG.environment,
release=__VERSION__,
- traces_sample_rate=SENTRY_CONFIG.traces_sample_rate,
- profiles_sample_rate=SENTRY_CONFIG.profiles_sample_rate,
+ # No traces_sample_rate: tracing is OpenTelemetry's. Errors and logs
+ # carry the active OTel trace id through OTLPIntegration.
+ profile_session_sample_rate=SENTRY_CONFIG.profile_session_sample_rate,
+ profile_lifecycle="manual",
send_default_pii=False,
before_send=filter_http_exception,
integrations=[
@@ -43,6 +47,10 @@ def init_sentry() -> bool:
LoguruIntegration(event_level=logging.CRITICAL),
SqlalchemyIntegration(),
RedisIntegration(),
+ # Spans are exported by app.core.telemetry, and the default
+ # Sentry propagator only speaks sentry-trace, which would break
+ # continuation of Cloudflare's W3C traceparent.
+ OTLPIntegration(setup_otlp_traces_exporter=False, setup_propagator=False),
],
disabled_integrations=[LoggingIntegration()],
enable_logs=True,
@@ -50,7 +58,20 @@ def init_sentry() -> bool:
return True
+def start_profiling() -> None:
+ """Start continuous profiling; a no-op unless this process was sampled by profile_session_sample_rate."""
+ if sentry_enabled():
+ sentry_profiler.start_profiler()
+
+
+def stop_profiling() -> None:
+ if sentry_enabled():
+ sentry_profiler.stop_profiler()
+
+
__all__ = [
"init_sentry",
"sentry_enabled",
+ "start_profiling",
+ "stop_profiling",
]
diff --git a/app/core/telemetry.py b/app/core/telemetry.py
new file mode 100644
index 0000000..71975e3
--- /dev/null
+++ b/app/core/telemetry.py
@@ -0,0 +1,127 @@
+import os
+import uuid
+from typing import Any
+
+from fastapi import FastAPI
+from opentelemetry import metrics, trace
+from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
+from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
+from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
+from opentelemetry.instrumentation.redis import RedisInstrumentor
+from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
+from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
+from opentelemetry.sdk.metrics import MeterProvider
+from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
+from opentelemetry.sdk.metrics.view import DropAggregation, View
+from opentelemetry.sdk.resources import SERVICE_INSTANCE_ID, SERVICE_NAME, SERVICE_VERSION, Resource
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.trace import Span
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+from app.config import TELEMETRY_CONFIG
+from app.schemas.constants import __VERSION__
+from app.utils.redact import redact_query, redact_url
+
+TRACER = trace.get_tracer("wynnsource")
+
+# Health probes would otherwise produce a root trace every few seconds.
+_EXCLUDED_URLS = "/healthz,/readyz"
+
+# Process-level runtime metrics only; host metrics come from node-exporter.
+_SYSTEM_METRICS: dict[str, list[str] | None] = {
+ "process.cpu.time": ["user", "system"],
+ "process.memory.usage": None,
+ "process.memory.virtual": None,
+ "process.thread.count": None,
+ "cpython.gc.collections": None,
+}
+
+_tracer_provider: TracerProvider | None = None
+_meter_provider: MeterProvider | None = None
+
+
+def _server_request_hook(span: Span, scope: dict[str, Any]) -> None:
+ if not span.is_recording() or not scope.get("query_string"):
+ return
+ attributes = getattr(span, "attributes", None) or {}
+ for key in ("http.target", "http.url", "url.full"):
+ if isinstance(value := attributes.get(key), str):
+ span.set_attribute(key, redact_url(value))
+ if isinstance(value := attributes.get("url.query"), str):
+ span.set_attribute("url.query", redact_query(value))
+
+
+def _resource() -> Resource:
+ # Resource.create() merges OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME on top of these.
+ resource = Resource.create({SERVICE_NAME: "wynnsource-server", SERVICE_VERSION: __VERSION__})
+ if SERVICE_INSTANCE_ID not in resource.attributes:
+ # Every replica pushes its own cumulative counters; without a unique
+ # instance id two pods would overwrite each other's series.
+ resource = resource.merge(Resource({SERVICE_INSTANCE_ID: str(uuid.uuid4())}))
+ return resource
+
+
+def setup_telemetry(app: FastAPI) -> bool:
+ """
+ Configure OpenTelemetry tracing and metrics, exported over OTLP/HTTP to
+ ``OTEL_EXPORTER_OTLP_ENDPOINT``. No-op when the endpoint is unset.
+
+ The sampler is the SDK default (``OTEL_TRACES_SAMPLER``, default
+ ``parentbased_always_on``): requests follow the caller's decision, jobs
+ and other root spans are always recorded.
+ """
+ global _tracer_provider, _meter_provider
+ if not TELEMETRY_CONFIG.enabled:
+ return False
+
+ # Stable HTTP semantic conventions (http.route, url.path, ...), matching Traefik's spans.
+ os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "http")
+
+ resource = _resource()
+
+ _tracer_provider = TracerProvider(resource=resource)
+ _tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+ trace.set_tracer_provider(_tracer_provider)
+
+ _meter_provider = MeterProvider(
+ resource=resource,
+ metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())],
+ # HTTP RED metrics come from Tempo span metrics and Traefik.
+ views=[View(instrument_name="http.server.*", aggregation=DropAggregation())],
+ )
+ metrics.set_meter_provider(_meter_provider)
+
+ FastAPIInstrumentor.instrument_app(
+ app,
+ excluded_urls=_EXCLUDED_URLS,
+ server_request_hook=_server_request_hook,
+ exclude_spans=["receive", "send"],
+ )
+ RedisInstrumentor().instrument()
+ HTTPXClientInstrumentor().instrument()
+ SystemMetricsInstrumentor(config=_SYSTEM_METRICS).instrument()
+ return True
+
+
+def instrument_engine(engine: AsyncEngine) -> None:
+ if _tracer_provider is None:
+ return
+ SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine, enable_commenter=False)
+
+
+def shutdown_telemetry() -> None:
+ """Flush pending spans and metrics; called on shutdown so rolling updates don't lose the last batch."""
+ if _tracer_provider is not None:
+ _tracer_provider.shutdown()
+ if _meter_provider is not None:
+ _meter_provider.shutdown()
+
+
+__all__ = [
+ "TRACER",
+ "instrument_engine",
+ "setup_telemetry",
+ "shutdown_telemetry",
+]
diff --git a/app/main.py b/app/main.py
index 345398e..34e54cc 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,109 +1,114 @@
-from contextlib import asynccontextmanager
-
-from fastapi import FastAPI, HTTPException
-from fastapi.exceptions import RequestValidationError, ResponseValidationError
-from fastapi.staticfiles import StaticFiles
-from scalar_fastapi import AgentScalarConfig, OpenAPISource, get_scalar_api_reference
-
-from app.config import DB_CONFIG
-from app.core.db import RedisClient, close_db, init_db
-from app.core.openapi import custom_openapi
-from app.core.scheduler import SCHEDULER
-from app.core.sentry import init_sentry
-from app.module.api.exception_handler import (
- generic_exception_handler,
- http_exception_handler,
- validation_exception_handler,
-)
-from app.module.api.router import Router
-from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__
-from app.schemas.response import STATUS_RESPONSE, StatusResponse
-
-init_sentry()
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- """
- Lifespan context manager for application.
- """
- try:
- SCHEDULER.start()
- await init_db()
- if DB_CONFIG.redis_dsn is not None:
- await RedisClient.init()
- yield
- finally:
- SCHEDULER.shutdown(wait=False)
- await close_db()
- if DB_CONFIG.redis_dsn is not None:
- await RedisClient.close()
-
-
-app = FastAPI(
- title=__NAME__,
- description=__DESCRIPTION__,
- version=__VERSION__,
- docs_url=None,
- redoc_url=None,
- lifespan=lifespan,
- license_info={
- "name": "GNU Affero General Public License v3.0 (AGPL-v3)",
- "identifier": "AGPL-3.0-or-later",
- "url": "https://www.gnu.org/licenses/agpl-3.0.html",
- },
-)
-
-app.include_router(Router)
-app.exception_handler(HTTPException)(http_exception_handler)
-app.exception_handler(RequestValidationError)(validation_exception_handler)
-app.exception_handler(ResponseValidationError)(validation_exception_handler)
-app.exception_handler(Exception)(generic_exception_handler)
-app.openapi = custom_openapi(app)
-
-
-@app.get("/")
-@app.get("/healthz", include_in_schema=False)
-async def read_root() -> StatusResponse:
- return STATUS_RESPONSE
-
-
-@app.get("/readyz", include_in_schema=False)
-async def read_readyz() -> StatusResponse:
- # TODO check db connections and other dependencies here
- # for now just return success for dev
- return STATUS_RESPONSE
-
-
-@app.get("/docs", include_in_schema=False)
-@app.get("/doc", include_in_schema=False)
-async def scalar_ui():
- return get_scalar_api_reference(
- title=f"{__NAME__} API Documentation",
- sources=[
- OpenAPISource(
- slug="wynnsource-api",
- url=app.openapi_url,
- )
- ],
- agent=AgentScalarConfig(disabled=True),
- scalar_favicon_url="./static/favicon.ico",
- default_open_all_tags=True,
- authentication={
- "preferredSecurityScheme": "APIKeyHeader",
- },
- )
-
-
-app.mount("/static", StaticFiles(directory="static"), name="static")
-
-
-if __name__ == "__main__":
- import uvicorn
-
- uvicorn.run(
- "app.main:app",
- host="0.0.0.0",
- port=8000,
- log_level="debug",
- )
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException
+from fastapi.exceptions import RequestValidationError, ResponseValidationError
+from fastapi.staticfiles import StaticFiles
+from scalar_fastapi import AgentScalarConfig, OpenAPISource, get_scalar_api_reference
+
+from app.config import DB_CONFIG
+from app.core.db import RedisClient, close_db, init_db
+from app.core.openapi import custom_openapi
+from app.core.scheduler import SCHEDULER
+from app.core.sentry import init_sentry, start_profiling, stop_profiling
+from app.core.telemetry import setup_telemetry, shutdown_telemetry
+from app.module.api.exception_handler import (
+ generic_exception_handler,
+ http_exception_handler,
+ validation_exception_handler,
+)
+from app.module.api.router import Router
+from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__
+from app.schemas.response import STATUS_RESPONSE, StatusResponse
+
+init_sentry()
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """
+ Lifespan context manager for application.
+ """
+ try:
+ start_profiling()
+ SCHEDULER.start()
+ await init_db()
+ if DB_CONFIG.redis_dsn is not None:
+ await RedisClient.init()
+ yield
+ finally:
+ SCHEDULER.shutdown(wait=False)
+ await close_db()
+ if DB_CONFIG.redis_dsn is not None:
+ await RedisClient.close()
+ stop_profiling()
+ shutdown_telemetry()
+
+
+app = FastAPI(
+ title=__NAME__,
+ description=__DESCRIPTION__,
+ version=__VERSION__,
+ docs_url=None,
+ redoc_url=None,
+ lifespan=lifespan,
+ license_info={
+ "name": "GNU Affero General Public License v3.0 (AGPL-v3)",
+ "identifier": "AGPL-3.0-or-later",
+ "url": "https://www.gnu.org/licenses/agpl-3.0.html",
+ },
+)
+setup_telemetry(app)
+
+app.include_router(Router)
+app.exception_handler(HTTPException)(http_exception_handler)
+app.exception_handler(RequestValidationError)(validation_exception_handler)
+app.exception_handler(ResponseValidationError)(validation_exception_handler)
+app.exception_handler(Exception)(generic_exception_handler)
+app.openapi = custom_openapi(app)
+
+
+@app.get("/")
+@app.get("/healthz", include_in_schema=False)
+async def read_root() -> StatusResponse:
+ return STATUS_RESPONSE
+
+
+@app.get("/readyz", include_in_schema=False)
+async def read_readyz() -> StatusResponse:
+ # TODO check db connections and other dependencies here
+ # for now just return success for dev
+ return STATUS_RESPONSE
+
+
+@app.get("/docs", include_in_schema=False)
+@app.get("/doc", include_in_schema=False)
+async def scalar_ui():
+ return get_scalar_api_reference(
+ title=f"{__NAME__} API Documentation",
+ sources=[
+ OpenAPISource(
+ slug="wynnsource-api",
+ url=app.openapi_url,
+ )
+ ],
+ agent=AgentScalarConfig(disabled=True),
+ scalar_favicon_url="./static/favicon.ico",
+ default_open_all_tags=True,
+ authentication={
+ "preferredSecurityScheme": "APIKeyHeader",
+ },
+ )
+
+
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(
+ "app.main:app",
+ host="0.0.0.0",
+ port=8000,
+ log_level="debug",
+ )
diff --git a/app/module/api/exception_handler.py b/app/module/api/exception_handler.py
index e5e7901..3643b79 100644
--- a/app/module/api/exception_handler.py
+++ b/app/module/api/exception_handler.py
@@ -1,40 +1,40 @@
-from fastapi import HTTPException, Request
-from fastapi.encoders import jsonable_encoder
-from fastapi.responses import JSONResponse
-from pydantic import ValidationError
-from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT, HTTP_500_INTERNAL_SERVER_ERROR
-
-from app.core.log import LOGGER
-from app.schemas.enums import ErrorCodes
-
-from .schema import GenericExceptionResponse, HTTPErrorDefinition, HTTPErrorResponse, ValidationErrorResponse
-
-
-def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
- """
- Generic exception handler API.
- """
- LOGGER.exception(exc)
- return GenericExceptionResponse(data={"error": "INTERNAL_ERROR"}, code=ErrorCodes.UNKNOWN_ERROR).to_response(
- HTTP_500_INTERNAL_SERVER_ERROR
- )
-
-
-def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
- """
- Global exception handler API.
- """
- LOGGER.error(f"HTTP {exc.status_code}: {exc.detail}")
- return HTTPErrorResponse(
- data=HTTPErrorDefinition(status_code=exc.status_code, error=exc.detail), code=ErrorCodes.HTTP_ERROR
- ).to_response(exc.status_code, exc.headers)
-
-
-def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse:
- """
- Validation exception handler API.
- """
- LOGGER.error(exc)
- return ValidationErrorResponse(
- data=jsonable_encoder(exc.errors()),
- ).to_response(HTTP_422_UNPROCESSABLE_CONTENT)
+from fastapi import HTTPException, Request
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import JSONResponse
+from pydantic import ValidationError
+from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT, HTTP_500_INTERNAL_SERVER_ERROR
+
+from app.core.log import LOGGER
+from app.schemas.enums import ErrorCodes
+
+from .schema import GenericExceptionResponse, HTTPErrorDefinition, HTTPErrorResponse, ValidationErrorResponse
+
+
+def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+ """
+ Generic exception handler API.
+ """
+ LOGGER.exception(exc)
+ return GenericExceptionResponse(data={"error": "INTERNAL_ERROR"}, code=ErrorCodes.UNKNOWN_ERROR).to_response(
+ HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
+ """
+ Global exception handler API.
+ """
+ LOGGER.error(f"HTTP {exc.status_code}: {exc.detail}")
+ return HTTPErrorResponse(
+ data=HTTPErrorDefinition(status_code=exc.status_code, error=exc.detail), code=ErrorCodes.HTTP_ERROR
+ ).to_response(exc.status_code, exc.headers)
+
+
+def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse:
+ """
+ Validation exception handler API.
+ """
+ LOGGER.error(exc)
+ return ValidationErrorResponse(
+ data=jsonable_encoder(exc.errors()),
+ ).to_response(HTTP_422_UNPROCESSABLE_CONTENT)
diff --git a/app/module/api/router.py b/app/module/api/router.py
index ec036b7..39cfd22 100644
--- a/app/module/api/router.py
+++ b/app/module/api/router.py
@@ -1,55 +1,55 @@
-from fastapi import APIRouter
-
-from app.core import metadata
-from app.core.router import DocedAPIRoute
-from app.module.beta.router import BetaRouter
-from app.module.manage.router import ManageRouter
-from app.module.market.router import MarketRouter
-from app.module.pool.router import PoolRouter
-from app.module.raid.router import RaidRouter
-from app.schemas.enums import ApiTag, ItemReturnType
-from app.schemas.response import StatusResponse, WCSResponse
-
-from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse
-from .service import MappingStorage, generate_random_item
-
-Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}})
-Router.include_router(ManageRouter)
-Router.include_router(PoolRouter)
-Router.include_router(MarketRouter)
-Router.include_router(BetaRouter)
-Router.include_router(RaidRouter)
-
-
-@Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC])
-@metadata.rate_limit(10, 60)
-@metadata.cached(expire=60)
-@metadata.permission("api.test")
-async def test_endpoint() -> StatusResponse:
- """
- Test endpoint for various features like rate limiting, caching, and permissions.
- """
- return StatusResponse()
-
-
-@Router.get("/mappings/{mapping_type}", summary="Get ID Mappings", tags=[ApiTag.MISC])
-@metadata.rate_limit(10, 60)
-@metadata.cached(expire=3600) # TTL 1 hour, updated by scheduled job
-async def get_mappings(mapping_type: MappingType) -> MappingResponse:
- """
- Get ID mappings for a given type.
- """
-
- storage = MappingStorage.get_instance()
- return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore")
-
-
-@Router.get("/item/random", summary="Get Random Item", tags=[ApiTag.MISC])
-@metadata.rate_limit(10, 60)
-async def get_random_item(
- item_return_type: ItemReturnType = ItemReturnType.B64,
-) -> WCSResponse[RandomItemResponse]:
- """
- Get a random item for testing purposes.
- """
- return WCSResponse(data=RandomItemResponse(item=item_return_type.format_item(generate_random_item())))
+from fastapi import APIRouter
+
+from app.core import metadata
+from app.core.router import DocedAPIRoute
+from app.module.beta.router import BetaRouter
+from app.module.manage.router import ManageRouter
+from app.module.market.router import MarketRouter
+from app.module.pool.router import PoolRouter
+from app.module.raid.router import RaidRouter
+from app.schemas.enums import ApiTag, ItemReturnType
+from app.schemas.response import StatusResponse, WCSResponse
+
+from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse
+from .service import MappingStorage, generate_random_item
+
+Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}})
+Router.include_router(ManageRouter)
+Router.include_router(PoolRouter)
+Router.include_router(MarketRouter)
+Router.include_router(BetaRouter)
+Router.include_router(RaidRouter)
+
+
+@Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC])
+@metadata.rate_limit(10, 60)
+@metadata.cached(expire=60)
+@metadata.permission("api.test")
+async def test_endpoint() -> StatusResponse:
+ """
+ Test endpoint for various features like rate limiting, caching, and permissions.
+ """
+ return StatusResponse()
+
+
+@Router.get("/mappings/{mapping_type}", summary="Get ID Mappings", tags=[ApiTag.MISC])
+@metadata.rate_limit(10, 60)
+@metadata.cached(expire=3600) # TTL 1 hour, updated by scheduled job
+async def get_mappings(mapping_type: MappingType) -> MappingResponse:
+ """
+ Get ID mappings for a given type.
+ """
+
+ storage = MappingStorage.get_instance()
+ return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore")
+
+
+@Router.get("/item/random", summary="Get Random Item", tags=[ApiTag.MISC])
+@metadata.rate_limit(10, 60)
+async def get_random_item(
+ item_return_type: ItemReturnType = ItemReturnType.B64,
+) -> WCSResponse[RandomItemResponse]:
+ """
+ Get a random item for testing purposes.
+ """
+ return WCSResponse(data=RandomItemResponse(item=item_return_type.format_item(generate_random_item())))
diff --git a/app/module/api/schema.py b/app/module/api/schema.py
index c139a4a..664064d 100644
--- a/app/module/api/schema.py
+++ b/app/module/api/schema.py
@@ -1,64 +1,64 @@
-from datetime import datetime
-from enum import StrEnum
-
-from pydantic import BaseModel
-from pydantic_core import ErrorDetails
-
-from app.schemas.enums import ErrorCodes
-from app.schemas.response.response import WCSResponse
-
-
-class GenericExceptionResponse(WCSResponse[dict]):
- """
- Generic exception response model.
- """
-
-
-class HTTPErrorDefinition(BaseModel):
- status_code: int
- error: str
-
-
-class HTTPErrorResponse(WCSResponse[dict]):
- """
- HTTP error response model.
- """
-
- data: HTTPErrorDefinition
- code: ErrorCodes = ErrorCodes.HTTP_ERROR
-
-
-class ValidationErrorResponse(WCSResponse[list]):
- """
- Validation error response model.
- """
-
- data: list[ErrorDetails]
- code: ErrorCodes = ErrorCodes.VALIDATION_ERROR
-
-
-class MappingType(StrEnum):
- IDENTIFICATION = "identification"
- SHINY = "shiny"
-
-
-class Mapping(BaseModel):
- id: int
- key: str
-
-
-class MappingResponse(BaseModel):
- """
- Response model for ID to name mappings.
- """
-
- lastUpdated: datetime
- data: list[Mapping]
-
-
-class RandomItemResponse(BaseModel):
- """
- Response model for random item endpoint.
- """
-
- item: str | dict
+from datetime import datetime
+from enum import StrEnum
+
+from pydantic import BaseModel
+from pydantic_core import ErrorDetails
+
+from app.schemas.enums import ErrorCodes
+from app.schemas.response.response import WCSResponse
+
+
+class GenericExceptionResponse(WCSResponse[dict]):
+ """
+ Generic exception response model.
+ """
+
+
+class HTTPErrorDefinition(BaseModel):
+ status_code: int
+ error: str
+
+
+class HTTPErrorResponse(WCSResponse[dict]):
+ """
+ HTTP error response model.
+ """
+
+ data: HTTPErrorDefinition
+ code: ErrorCodes = ErrorCodes.HTTP_ERROR
+
+
+class ValidationErrorResponse(WCSResponse[list]):
+ """
+ Validation error response model.
+ """
+
+ data: list[ErrorDetails]
+ code: ErrorCodes = ErrorCodes.VALIDATION_ERROR
+
+
+class MappingType(StrEnum):
+ IDENTIFICATION = "identification"
+ SHINY = "shiny"
+
+
+class Mapping(BaseModel):
+ id: int
+ key: str
+
+
+class MappingResponse(BaseModel):
+ """
+ Response model for ID to name mappings.
+ """
+
+ lastUpdated: datetime
+ data: list[Mapping]
+
+
+class RandomItemResponse(BaseModel):
+ """
+ Response model for random item endpoint.
+ """
+
+ item: str | dict
diff --git a/app/module/api/service.py b/app/module/api/service.py
index bbf54fd..95939e6 100644
--- a/app/module/api/service.py
+++ b/app/module/api/service.py
@@ -7,7 +7,7 @@
from apscheduler.triggers.interval import IntervalTrigger
from app.core.log import LOGGER
-from app.core.scheduler import SCHEDULER
+from app.core.scheduler import scheduled_job
from wynnsource import WynnSourceItem
from wynnsource.common.enums_pb2 import RARITY_CRAFTED
@@ -73,7 +73,8 @@ async def update_mapping(self, mapping_type: MappingType):
self.mappings[mapping_type] = mapping
-@SCHEDULER.scheduled_job(
+# Refreshes this replica's in-memory MappingStorage, so it runs everywhere (no lock).
+@scheduled_job(
IntervalTrigger(minutes=60),
id="mapping_update_trigger",
name="Mapping Update",
diff --git a/app/module/beta/service.py b/app/module/beta/service.py
index 8a711c3..2e67a91 100644
--- a/app/module/beta/service.py
+++ b/app/module/beta/service.py
@@ -3,6 +3,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.log import LOGGER
+from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS
from wynnsource import WynnSourceItem
from .config import BETA_CONFIG
@@ -13,8 +14,10 @@
async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None:
+ SUBMISSION_ITEMS.record(len(submission.items), {"module": "beta"})
if not any(version in submission.mod_version for version in allowed_version):
LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission")
+ SUBMISSIONS.add(len(submission.items), {"module": "beta", "result": "rejected"})
return
itemRepo = BetaItemRepository(session)
succeeds = 0
@@ -27,6 +30,7 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe
existing = WynnSourceItem.FromString(existing.item) if existing else None
if existing and item == existing:
LOGGER.debug(f"Item from submission is identical to existing item: {item.name}")
+ SUBMISSIONS.add(1, {"module": "beta", "result": "unchanged"})
continue
if existing and item != existing:
LOGGER.debug(f"Item from submission is different from existing item: {item.name}," + " overwriting")
@@ -34,10 +38,11 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe
item.gear.powders.extend(existing.gear.powders)
await itemRepo.add_item(item)
succeeds += 1
+ SUBMISSIONS.add(1, {"module": "beta", "result": "accepted"})
except Exception as e:
LOGGER.debug(f"Failed to add item from submission, error: {e}")
# Silently ignore failed items
- pass
+ SUBMISSIONS.add(1, {"module": "beta", "result": "rejected"})
LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta submission")
@@ -71,8 +76,10 @@ async def get_beta_ingredients_by_name(session: AsyncSession, name: list[str]) -
async def handle_patch_submission(submission: ItemPatchSubmission, session: AsyncSession) -> None:
+ SUBMISSION_ITEMS.record(len(submission.items), {"module": "beta_patch"})
if not any(version in submission.mod_version for version in allowed_version):
LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission")
+ SUBMISSIONS.add(len(submission.items), {"module": "beta_patch", "result": "rejected"})
return
itemRepo = BetaItemRepository(session)
succeeds = 0
@@ -84,16 +91,18 @@ async def handle_patch_submission(submission: ItemPatchSubmission, session: Asyn
existing = await itemRepo.get_item(item.name)
if not existing:
LOGGER.debug(f"Item from patch submission does not exist in beta: {item.name}")
+ SUBMISSIONS.add(1, {"module": "beta_patch", "result": "rejected"})
continue
existing_item = WynnSourceItem.FromString(existing.item)
del existing_item.gear.powders[:]
existing_item.gear.powders.extend(item.gear.powders)
await itemRepo.add_item(existing_item)
succeeds += 1
+ SUBMISSIONS.add(1, {"module": "beta_patch", "result": "accepted"})
except Exception as e:
LOGGER.debug(f"Failed to patch item from submission, error: {e}")
# Silently ignore failed items
- pass
+ SUBMISSIONS.add(1, {"module": "beta_patch", "result": "rejected"})
LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta patch submission")
diff --git a/app/module/manage/router.py b/app/module/manage/router.py
index 5445dc4..4d9fc63 100644
--- a/app/module/manage/router.py
+++ b/app/module/manage/router.py
@@ -1,152 +1,152 @@
-from fastapi import APIRouter, Query
-
-import app.core.metadata as metadata
-from app.core.db import SessionDep
-from app.core.log import LOGGER
-from app.core.router import DocedAPIRoute
-from app.core.security.auth import IpDep, UserDep, hash_token, hash_tokens
-from app.core.security.model import User, UserRepository
-from app.schemas.enums import ApiTag
-from app.schemas.response import EMPTY_RESPONSE, WCSResponse
-
-from .schema import UserInfoRequest, UserInfoResponse
-
-ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=[ApiTag.MANAGEMENT])
-
-
-@ManageRouter.get("/user", summary="Get User Info by Token")
-@metadata.permission(permission="admin.users.read")
-async def get_user_info_by_token(
- session: SessionDep,
- token: list[str] = Query(),
-) -> WCSResponse[list[UserInfoResponse]]:
- """
- Get information about a user by their token.
- """
- userRepo = UserRepository(session)
- tokens = hash_tokens(token)
-
- users = await userRepo.get_users_by_tokens(tokens)
- return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users])
-
-
-@ManageRouter.get("/user/all", summary="List Users")
-@metadata.permission("admin.users.read")
-async def get_users(
- session: SessionDep,
- inactive: bool = False,
-) -> WCSResponse[list[UserInfoResponse]]:
- """
- List all users with their permissions.
- """
- userRepo = UserRepository(session)
- users = await userRepo.list_users(include_inactive=inactive)
- return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users])
-
-
-@ManageRouter.get("/user/self", summary="Get Self User Info")
-async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]:
- """
- Get information about the current user, including permissions and common IPs.
- """
- return WCSResponse(data=UserInfoResponse.model_validate(user))
-
-
-@ManageRouter.post("/user/register", summary="Register New User")
-@metadata.rate_limit(1, 3600)
-async def register_user(
- token: str,
- session: SessionDep,
- ip: IpDep = "unknown",
-) -> WCSResponse[UserInfoResponse]:
- """
- Register a new user with the given token.
- """
- userRepo = UserRepository(session)
- user = User(
- token=hash_token(token),
- is_active=True,
- permissions=[],
- creation_ip=ip,
- )
- await userRepo.save(user)
- LOGGER.info(f"Registered new user with token: {token}")
- return WCSResponse(data=UserInfoResponse.model_validate(user))
-
-
-@ManageRouter.post("/user/create", summary="Create New Users")
-@metadata.permission(permission="admin.users.write")
-async def create_user(
- tokens: list[UserInfoRequest], session: SessionDep, ip: IpDep = "unknown"
-) -> WCSResponse[list[UserInfoResponse]]:
- """
- Create a new user with specified token and permissions.
- """
- userRepo = UserRepository(session)
- created_tokens: list[UserInfoResponse] = []
- for token_info in tokens:
- user = User(
- token=hash_token(token_info.token),
- is_active=True,
- permissions=token_info.permissions,
- expires_at=token_info.expires_at,
- creation_ip=ip,
- )
- await userRepo.save(user)
- created_tokens.append(UserInfoResponse.model_validate(user))
-
- LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}")
- return WCSResponse(data=created_tokens)
-
-
-@ManageRouter.delete("/user", summary="Delete User")
-@metadata.permission(permission="admin.users.write")
-async def delete_user(tokens: list[str], session: SessionDep) -> WCSResponse[dict]:
- """
- Delete a user by their token string.
- """
- tokens = hash_tokens(tokens)
- userRepo = UserRepository(session)
- await userRepo.delete(tokens)
- LOGGER.info(f"Deleted {len(tokens)} users: {tokens}")
- return EMPTY_RESPONSE
-
-
-@ManageRouter.put("/user/permissions", summary="Add Permissions to User")
-@metadata.permission(permission="admin.users.write")
-async def add_permissions_to_user(
- token_str: list[str], permissions: list[str], session: SessionDep
-) -> WCSResponse[list[UserInfoResponse]]:
- """
- Add permissions to existing users.
- """
- userRepo = UserRepository(session)
- token_strs = hash_tokens(token_str)
- users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True)
-
- for user in users:
- user.permissions = list(set(user.permissions + permissions))
-
- updated_user_infos = [UserInfoResponse.model_validate(user) for user in users]
- LOGGER.info(f"Added permissions {permissions} to users: {token_strs}")
- return WCSResponse(data=updated_user_infos)
-
-
-@ManageRouter.delete("/user/permissions", summary="Remove Permissions from User")
-@metadata.permission(permission="admin.users.write")
-async def remove_permissions_from_user(
- token_str: list[str], permissions: list[str], session: SessionDep
-) -> WCSResponse[list[UserInfoResponse]]:
- """
- Remove permissions from existing users.
- """
- token_strs = hash_tokens(token_str)
- userRepo = UserRepository(session)
- users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True)
-
- for user in users:
- user.permissions = list(set(user.permissions) - set(permissions))
-
- updated_user_infos = [UserInfoResponse.model_validate(user) for user in users]
- LOGGER.info(f"Removed permissions {permissions} from users: {token_strs}")
- return WCSResponse(data=updated_user_infos)
+from fastapi import APIRouter, Query
+
+import app.core.metadata as metadata
+from app.core.db import SessionDep
+from app.core.log import LOGGER
+from app.core.router import DocedAPIRoute
+from app.core.security.auth import IpDep, UserDep, hash_token, hash_tokens
+from app.core.security.model import User, UserRepository
+from app.schemas.enums import ApiTag
+from app.schemas.response import EMPTY_RESPONSE, WCSResponse
+
+from .schema import UserInfoRequest, UserInfoResponse
+
+ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=[ApiTag.MANAGEMENT])
+
+
+@ManageRouter.get("/user", summary="Get User Info by Token")
+@metadata.permission(permission="admin.users.read")
+async def get_user_info_by_token(
+ session: SessionDep,
+ token: list[str] = Query(),
+) -> WCSResponse[list[UserInfoResponse]]:
+ """
+ Get information about a user by their token.
+ """
+ userRepo = UserRepository(session)
+ tokens = hash_tokens(token)
+
+ users = await userRepo.get_users_by_tokens(tokens)
+ return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users])
+
+
+@ManageRouter.get("/user/all", summary="List Users")
+@metadata.permission("admin.users.read")
+async def get_users(
+ session: SessionDep,
+ inactive: bool = False,
+) -> WCSResponse[list[UserInfoResponse]]:
+ """
+ List all users with their permissions.
+ """
+ userRepo = UserRepository(session)
+ users = await userRepo.list_users(include_inactive=inactive)
+ return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users])
+
+
+@ManageRouter.get("/user/self", summary="Get Self User Info")
+async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]:
+ """
+ Get information about the current user, including permissions and common IPs.
+ """
+ return WCSResponse(data=UserInfoResponse.model_validate(user))
+
+
+@ManageRouter.post("/user/register", summary="Register New User")
+@metadata.rate_limit(1, 3600)
+async def register_user(
+ token: str,
+ session: SessionDep,
+ ip: IpDep = "unknown",
+) -> WCSResponse[UserInfoResponse]:
+ """
+ Register a new user with the given token.
+ """
+ userRepo = UserRepository(session)
+ user = User(
+ token=hash_token(token),
+ is_active=True,
+ permissions=[],
+ creation_ip=ip,
+ )
+ await userRepo.save(user)
+ LOGGER.info(f"Registered new user with token: {token}")
+ return WCSResponse(data=UserInfoResponse.model_validate(user))
+
+
+@ManageRouter.post("/user/create", summary="Create New Users")
+@metadata.permission(permission="admin.users.write")
+async def create_user(
+ tokens: list[UserInfoRequest], session: SessionDep, ip: IpDep = "unknown"
+) -> WCSResponse[list[UserInfoResponse]]:
+ """
+ Create a new user with specified token and permissions.
+ """
+ userRepo = UserRepository(session)
+ created_tokens: list[UserInfoResponse] = []
+ for token_info in tokens:
+ user = User(
+ token=hash_token(token_info.token),
+ is_active=True,
+ permissions=token_info.permissions,
+ expires_at=token_info.expires_at,
+ creation_ip=ip,
+ )
+ await userRepo.save(user)
+ created_tokens.append(UserInfoResponse.model_validate(user))
+
+ LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}")
+ return WCSResponse(data=created_tokens)
+
+
+@ManageRouter.delete("/user", summary="Delete User")
+@metadata.permission(permission="admin.users.write")
+async def delete_user(tokens: list[str], session: SessionDep) -> WCSResponse[dict]:
+ """
+ Delete a user by their token string.
+ """
+ tokens = hash_tokens(tokens)
+ userRepo = UserRepository(session)
+ await userRepo.delete(tokens)
+ LOGGER.info(f"Deleted {len(tokens)} users: {tokens}")
+ return EMPTY_RESPONSE
+
+
+@ManageRouter.put("/user/permissions", summary="Add Permissions to User")
+@metadata.permission(permission="admin.users.write")
+async def add_permissions_to_user(
+ token_str: list[str], permissions: list[str], session: SessionDep
+) -> WCSResponse[list[UserInfoResponse]]:
+ """
+ Add permissions to existing users.
+ """
+ userRepo = UserRepository(session)
+ token_strs = hash_tokens(token_str)
+ users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True)
+
+ for user in users:
+ user.permissions = list(set(user.permissions + permissions))
+
+ updated_user_infos = [UserInfoResponse.model_validate(user) for user in users]
+ LOGGER.info(f"Added permissions {permissions} to users: {token_strs}")
+ return WCSResponse(data=updated_user_infos)
+
+
+@ManageRouter.delete("/user/permissions", summary="Remove Permissions from User")
+@metadata.permission(permission="admin.users.write")
+async def remove_permissions_from_user(
+ token_str: list[str], permissions: list[str], session: SessionDep
+) -> WCSResponse[list[UserInfoResponse]]:
+ """
+ Remove permissions from existing users.
+ """
+ token_strs = hash_tokens(token_str)
+ userRepo = UserRepository(session)
+ users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True)
+
+ for user in users:
+ user.permissions = list(set(user.permissions) - set(permissions))
+
+ updated_user_infos = [UserInfoResponse.model_validate(user) for user in users]
+ LOGGER.info(f"Removed permissions {permissions} from users: {token_strs}")
+ return WCSResponse(data=updated_user_infos)
diff --git a/app/module/manage/schema.py b/app/module/manage/schema.py
index 78d358c..8840478 100644
--- a/app/module/manage/schema.py
+++ b/app/module/manage/schema.py
@@ -1,28 +1,28 @@
-from datetime import datetime
-
-from pydantic import BaseModel, ConfigDict
-
-
-class UserInfoRequest(BaseModel):
- token: str
- permissions: list[str]
- expires_at: datetime | None = None
-
-
-class UserInfoResponse(BaseModel):
- model_config = ConfigDict(from_attributes=True)
-
- id: int
-
- token: str
- permissions: list[str]
-
- created_at: datetime
- expires_at: datetime | None = None
- is_active: bool = True
-
- creation_ip: str
- # A list of common IPs used by this user, ordered from oldest to newest.
- common_ips: list[str]
-
- score: int
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class UserInfoRequest(BaseModel):
+ token: str
+ permissions: list[str]
+ expires_at: datetime | None = None
+
+
+class UserInfoResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: int
+
+ token: str
+ permissions: list[str]
+
+ created_at: datetime
+ expires_at: datetime | None = None
+ is_active: bool = True
+
+ creation_ip: str
+ # A list of common IPs used by this user, ordered from oldest to newest.
+ common_ips: list[str]
+
+ score: int
diff --git a/app/module/market/router.py b/app/module/market/router.py
index fd55c88..80aed85 100644
--- a/app/module/market/router.py
+++ b/app/module/market/router.py
@@ -1,6 +1,6 @@
-from fastapi import APIRouter
-
-from app.core.router import DocedAPIRoute
-from app.schemas.enums import ApiTag
-
-MarketRouter = APIRouter(route_class=DocedAPIRoute, prefix="/market", tags=[ApiTag.MARKET])
+from fastapi import APIRouter
+
+from app.core.router import DocedAPIRoute
+from app.schemas.enums import ApiTag
+
+MarketRouter = APIRouter(route_class=DocedAPIRoute, prefix="/market", tags=[ApiTag.MARKET])
diff --git a/app/module/pool/router.py b/app/module/pool/router.py
index a2edcdf..08c1967 100644
--- a/app/module/pool/router.py
+++ b/app/module/pool/router.py
@@ -1,110 +1,112 @@
-import datetime
-
-from fastapi import APIRouter, HTTPException
-from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT
-
-from app.core import metadata
-from app.core.db import SessionDep, get_session
-from app.core.rate_limiter import ip_based_key_func, user_based_key_func
-from app.core.router import DocedAPIRoute
-from app.core.security.auth import UserDep
-from app.schemas.enums import ApiTag, ItemReturnType
-from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse
-
-from .config import POOL_REFRESH_CONFIG
-from .schema import (
- LootPoolRegion,
- PoolConsensusResponse,
- PoolSubmissionSchema,
- PoolType,
- RaidRegion,
-)
-from .service import compute_pool_consensus, get_pool_consensus
-from .service import submit_pool_data as svc_submit_pool_data
-
-PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL])
-
-
-@PoolRouter.post("/submit", summary="Submit Pool Data")
-@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func)
-async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse:
- """
- Endpoint for clients to submit pool data.
- """
- for submission in data:
- try:
- # We want to process each submission
- # in a different sessions to avoid one bad submission breaking others
- async with get_session() as session:
- await svc_submit_pool_data(session, submission, user)
- except ValueError:
- continue
-
- return EMPTY_RESPONSE
-
-
-@PoolRouter.get("/pools/{pool_type}/{region}", summary="Get Current Pool by Type and Region")
-@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func)
-@metadata.cached(expire=120)
-async def get_pools_by_type_and_region(
- pool_type: PoolType,
- region: LootPoolRegion | RaidRegion,
- session: SessionDep,
- item_return_type: ItemReturnType = ItemReturnType.B64,
-) -> WCSResponse[PoolConsensusResponse]:
- """
- Get pools by type and region.
- """
- try:
- rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation(
- datetime.datetime.now(tz=datetime.UTC)
- )
- consensus_by_page = await get_pool_consensus(
- session,
- pool_type,
- region,
- rotation_start=rotation.start,
- )
-
- if not consensus_by_page:
- data = PoolConsensusResponse(
- pool_type=pool_type,
- region=region,
- rotation_start=rotation.start,
- rotation_end=rotation.end,
- page_consensus=[],
- )
- else:
- page_consensus = []
- for page, (items, confidence) in consensus_by_page.items():
- page_consensus.append(
- PoolConsensusResponse.PageConsensus(
- page=page,
- items=item_return_type.format_items(items),
- confidence=confidence,
- )
- )
-
- data = PoolConsensusResponse(
- pool_type=pool_type,
- region=region,
- rotation_start=rotation.start,
- rotation_end=rotation.end,
- page_consensus=page_consensus,
- )
-
- except ValueError as e:
- raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))
-
- return WCSResponse(data=data)
-
-
-@PoolRouter.get("/pools/recalc", summary="Force Recalculate Pool Consensus")
-@metadata.permission("pool.recalc")
-async def recalculate_pools() -> WCSResponse[dict]:
- """
- Force recalculate pool consensus.
- This is useful for admins to fix any issues with the consensus calculation.
- """
- recalculated = await compute_pool_consensus()
- return WCSResponse.from_message(f"Recalculated {recalculated} pool(s)")
+import datetime
+
+from fastapi import APIRouter, HTTPException
+from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT
+
+from app.core import metadata
+from app.core.db import SessionDep, get_session
+from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS
+from app.core.rate_limiter import ip_based_key_func, user_based_key_func
+from app.core.router import DocedAPIRoute
+from app.core.security.auth import UserDep
+from app.schemas.enums import ApiTag, ItemReturnType
+from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse
+
+from .config import POOL_REFRESH_CONFIG
+from .schema import (
+ LootPoolRegion,
+ PoolConsensusResponse,
+ PoolSubmissionSchema,
+ PoolType,
+ RaidRegion,
+)
+from .service import compute_pool_consensus, get_pool_consensus
+from .service import submit_pool_data as svc_submit_pool_data
+
+PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL])
+
+
+@PoolRouter.post("/submit", summary="Submit Pool Data")
+@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func)
+async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse:
+ """
+ Endpoint for clients to submit pool data.
+ """
+ SUBMISSION_ITEMS.record(len(data), {"module": "pool"})
+ for submission in data:
+ try:
+ # We want to process each submission
+ # in a different sessions to avoid one bad submission breaking others
+ async with get_session() as session:
+ await svc_submit_pool_data(session, submission, user)
+ except ValueError:
+ SUBMISSIONS.add(1, {"module": "pool", "result": "rejected"})
+ continue
+ SUBMISSIONS.add(1, {"module": "pool", "result": "accepted"})
+
+ return EMPTY_RESPONSE
+
+
+@PoolRouter.get("/pools/{pool_type}/{region}", summary="Get Current Pool by Type and Region")
+@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func)
+@metadata.cached(expire=120)
+async def get_pools_by_type_and_region(
+ pool_type: PoolType,
+ region: LootPoolRegion | RaidRegion,
+ session: SessionDep,
+ item_return_type: ItemReturnType = ItemReturnType.B64,
+) -> WCSResponse[PoolConsensusResponse]:
+ """
+ Get pools by type and region.
+ """
+ try:
+ rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC))
+ consensus_by_page = await get_pool_consensus(
+ session,
+ pool_type,
+ region,
+ rotation_start=rotation.start,
+ )
+
+ if not consensus_by_page:
+ data = PoolConsensusResponse(
+ pool_type=pool_type,
+ region=region,
+ rotation_start=rotation.start,
+ rotation_end=rotation.end,
+ page_consensus=[],
+ )
+ else:
+ page_consensus = []
+ for page, (items, confidence) in consensus_by_page.items():
+ page_consensus.append(
+ PoolConsensusResponse.PageConsensus(
+ page=page,
+ items=item_return_type.format_items(items),
+ confidence=confidence,
+ )
+ )
+
+ data = PoolConsensusResponse(
+ pool_type=pool_type,
+ region=region,
+ rotation_start=rotation.start,
+ rotation_end=rotation.end,
+ page_consensus=page_consensus,
+ )
+
+ except ValueError as e:
+ raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e))
+
+ return WCSResponse(data=data)
+
+
+@PoolRouter.get("/pools/recalc", summary="Force Recalculate Pool Consensus")
+@metadata.permission("pool.recalc")
+async def recalculate_pools() -> WCSResponse[dict]:
+ """
+ Force recalculate pool consensus.
+ This is useful for admins to fix any issues with the consensus calculation.
+ """
+ recalculated = await compute_pool_consensus()
+ return WCSResponse.from_message(f"Recalculated {recalculated} pool(s)")
diff --git a/app/module/pool/service.py b/app/module/pool/service.py
index 2a2291d..e767b87 100644
--- a/app/module/pool/service.py
+++ b/app/module/pool/service.py
@@ -8,7 +8,8 @@
from app.core.db import get_session
from app.core.log import LOGGER
-from app.core.scheduler import SCHEDULER
+from app.core.metrics import consensus_run
+from app.core.scheduler import SCHEDULER, instrument_job, scheduled_job
from app.core.score import Tier
from app.core.security.model import User
from wynnsource import WynnSourceItem
@@ -100,9 +101,10 @@ def calculate_submission_weight(user: User, fuzzy: bool = False) -> float:
return weight * (0.5 if fuzzy else 1.0)
-@SCHEDULER.scheduled_job(
+@scheduled_job(
IntervalTrigger(minutes=20),
id="compute_pool_consensus",
+ lock_ttl=datetime.timedelta(minutes=18),
misfire_grace_time=60,
coalesce=True, # Coalesce multiple missed executions into one
)
@@ -114,6 +116,7 @@ async def compute_pool_consensus() -> int:
BOOST_INTERVAL = datetime.timedelta(minutes=2)
+BOOST_LOCK_TTL = BOOST_INTERVAL - datetime.timedelta(seconds=20)
BOOST_LEAD = datetime.timedelta(minutes=30) # reset 前多久开始
BOOST_TAIL = datetime.timedelta(minutes=30) # reset 后多久结束
@@ -122,7 +125,9 @@ def _boost_job_id(pool_type: PoolType) -> str:
return f"compute_pool_consensus_boost:{pool_type.value}"
-@SCHEDULER.scheduled_job(
+# Registers this replica's local boost jobs, so it runs everywhere (no lock);
+# the boost jobs themselves write to the DB and are locked.
+@scheduled_job(
CronTrigger(hour=0, minute=5),
id="schedule_pool_boosts",
misfire_grace_time=600,
@@ -148,7 +153,12 @@ async def _schedule_boost_for_pool(pool_type: PoolType) -> None:
boost_end = reset_at + BOOST_TAIL
SCHEDULER.add_job(
- compute_pool_consensus_for_pool,
+ instrument_job(
+ "compute_pool_consensus_boost",
+ compute_pool_consensus_for_pool,
+ lock_ttl=BOOST_LOCK_TTL,
+ lock_key=_boost_job_id(pool_type),
+ ),
args=[pool_type],
trigger=IntervalTrigger(
minutes=int(BOOST_INTERVAL.total_seconds() // 60),
@@ -164,6 +174,11 @@ async def _schedule_boost_for_pool(pool_type: PoolType) -> None:
async def compute_pool_consensus_for_pool(pool_type: PoolType) -> int:
+ with consensus_run("pool", {"pool_type": pool_type.value}) as record:
+ return record(await _compute_pool_consensus_for_pool(pool_type))
+
+
+async def _compute_pool_consensus_for_pool(pool_type: PoolType) -> int:
async with get_session() as session:
# Step 1: Fetch all active pools that need consensus computation
poolRepo = PoolRepository(session)
diff --git a/app/module/raid/router.py b/app/module/raid/router.py
index 3af4d02..ed66766 100644
--- a/app/module/raid/router.py
+++ b/app/module/raid/router.py
@@ -5,6 +5,7 @@
from app.core import metadata
from app.core.db import SessionDep, get_session
+from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS
from app.core.rate_limiter import ip_based_key_func, user_based_key_func
from app.core.router import DocedAPIRoute
from app.core.security.auth import UserDep
@@ -36,12 +37,15 @@ async def submit_gambit_data(data: list[GambitSubmissionSchema], user: UserDep)
Submit gambit data for the current rotation.
Partial submissions are supported (1-4 gambits per region).
"""
+ SUBMISSION_ITEMS.record(len(data), {"module": "gambit"})
for submission in data:
try:
async with get_session() as session:
await svc_submit_gambit_data(session, submission, user)
except ValueError:
+ SUBMISSIONS.add(1, {"module": "gambit", "result": "rejected"})
continue
+ SUBMISSIONS.add(1, {"module": "gambit", "result": "accepted"})
return EMPTY_RESPONSE
diff --git a/app/module/raid/service.py b/app/module/raid/service.py
index b3df519..045f0b8 100644
--- a/app/module/raid/service.py
+++ b/app/module/raid/service.py
@@ -5,7 +5,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_session
-from app.core.scheduler import SCHEDULER
+from app.core.metrics import consensus_run
+from app.core.scheduler import scheduled_job
from app.core.security.model import User
from app.module.pool.service import calculate_submission_weight
@@ -53,13 +54,19 @@ async def submit_gambit_data(session: AsyncSession, data: GambitSubmissionSchema
await submission_repo.save(submission)
-@SCHEDULER.scheduled_job(
+@scheduled_job(
IntervalTrigger(minutes=5),
id="compute_gambit_consensus",
+ lock_ttl=datetime.timedelta(seconds=270),
misfire_grace_time=60,
coalesce=True,
)
async def compute_gambit_consensus() -> int:
+ with consensus_run("gambit") as record:
+ return record(await _compute_gambit_consensus())
+
+
+async def _compute_gambit_consensus() -> int:
async with get_session() as session:
gambit_repo = GambitRepository(session)
rotation = get_gambit_rotation(datetime.datetime.now(tz=datetime.UTC))
diff --git a/app/schemas/constants.py b/app/schemas/constants.py
index 55aaf39..d249132 100644
--- a/app/schemas/constants.py
+++ b/app/schemas/constants.py
@@ -1,39 +1,39 @@
-import enum
-import os
-import tomllib
-
-
-class MediaType(enum.StrEnum):
- JSON = "application/json"
- HTML = "text/html"
- TEXT = "text/plain"
- XML = "application/xml"
- FORM_URLENCODED = "application/x-www-form-urlencoded"
- MULTIPART_FORM_DATA = "multipart/form-data"
-
- @classmethod
- def get_media_type(cls, media_type: str) -> str:
- return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value
-
-
-with open("pyproject.toml", "rb") as f:
- pyproject = tomllib.load(f)
-
-__NAME__ = "WynnSource Server"
-_base_version: str = pyproject["project"]["version"]
-_version_suffix = os.environ.get("WCS_VERSION_SUFFIX", "")
-__VERSION__: str = f"{_base_version}{_version_suffix}" if _version_suffix else _base_version
-__DESCRIPTION__: str = pyproject["project"]["description"]
-__REVISION__ = 2
-
-
-INJECTED_NAMESPACE = "__wcs_injected_"
-
-__all__ = [
- "INJECTED_NAMESPACE",
- "__DESCRIPTION__",
- "__NAME__",
- "__REVISION__",
- "__VERSION__",
- "MediaType",
-]
+import enum
+import os
+import tomllib
+
+
+class MediaType(enum.StrEnum):
+ JSON = "application/json"
+ HTML = "text/html"
+ TEXT = "text/plain"
+ XML = "application/xml"
+ FORM_URLENCODED = "application/x-www-form-urlencoded"
+ MULTIPART_FORM_DATA = "multipart/form-data"
+
+ @classmethod
+ def get_media_type(cls, media_type: str) -> str:
+ return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value
+
+
+with open("pyproject.toml", "rb") as f:
+ pyproject = tomllib.load(f)
+
+__NAME__ = "WynnSource Server"
+_base_version: str = pyproject["project"]["version"]
+_version_suffix = os.environ.get("WCS_VERSION_SUFFIX", "")
+__VERSION__: str = f"{_base_version}{_version_suffix}" if _version_suffix else _base_version
+__DESCRIPTION__: str = pyproject["project"]["description"]
+__REVISION__ = 2
+
+
+INJECTED_NAMESPACE = "__wcs_injected_"
+
+__all__ = [
+ "INJECTED_NAMESPACE",
+ "__DESCRIPTION__",
+ "__NAME__",
+ "__REVISION__",
+ "__VERSION__",
+ "MediaType",
+]
diff --git a/app/schemas/enums/__init__.py b/app/schemas/enums/__init__.py
index 9cc563f..0965efd 100644
--- a/app/schemas/enums/__init__.py
+++ b/app/schemas/enums/__init__.py
@@ -1,9 +1,9 @@
-from .error import ErrorCodes
-from .item import ItemReturnType
-from .tag import ApiTag
-
-__all__ = [
- "ApiTag",
- "ErrorCodes",
- "ItemReturnType",
-]
+from .error import ErrorCodes
+from .item import ItemReturnType
+from .tag import ApiTag
+
+__all__ = [
+ "ApiTag",
+ "ErrorCodes",
+ "ItemReturnType",
+]
diff --git a/app/schemas/enums/error.py b/app/schemas/enums/error.py
index 07e5b67..03e2378 100644
--- a/app/schemas/enums/error.py
+++ b/app/schemas/enums/error.py
@@ -1,13 +1,13 @@
-import enum
-
-
-class ErrorCodes(enum.StrEnum):
- """
- Enum for error codes.
- """
-
- OK = "OK"
- UNKNOWN_ERROR = "UNKNOWN_ERROR"
- VALIDATION_ERROR = "VALIDATION_ERROR"
- NOT_FOUND = "NOT_FOUND"
- HTTP_ERROR = "HTTP_ERROR"
+import enum
+
+
+class ErrorCodes(enum.StrEnum):
+ """
+ Enum for error codes.
+ """
+
+ OK = "OK"
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
+ VALIDATION_ERROR = "VALIDATION_ERROR"
+ NOT_FOUND = "NOT_FOUND"
+ HTTP_ERROR = "HTTP_ERROR"
diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py
index dfbcc31..712e7f9 100644
--- a/app/schemas/response/__init__.py
+++ b/app/schemas/response/__init__.py
@@ -1,4 +1,4 @@
-from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE
-from .response import EmptyResponse, StatusResponse, WCSResponse
-
-__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "EmptyResponse", "StatusResponse", "WCSResponse"]
+from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE
+from .response import EmptyResponse, StatusResponse, WCSResponse
+
+__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "EmptyResponse", "StatusResponse", "WCSResponse"]
diff --git a/app/schemas/response/default_response.py b/app/schemas/response/default_response.py
index 3363bed..0e57548 100644
--- a/app/schemas/response/default_response.py
+++ b/app/schemas/response/default_response.py
@@ -1,4 +1,4 @@
-from .response import EmptyResponse, StatusResponse
-
-STATUS_RESPONSE = StatusResponse()
-EMPTY_RESPONSE = EmptyResponse()
+from .response import EmptyResponse, StatusResponse
+
+STATUS_RESPONSE = StatusResponse()
+EMPTY_RESPONSE = EmptyResponse()
diff --git a/app/schemas/response/response.py b/app/schemas/response/response.py
index 3991bba..6a9afdf 100644
--- a/app/schemas/response/response.py
+++ b/app/schemas/response/response.py
@@ -1,76 +1,76 @@
-from collections.abc import Mapping
-from datetime import UTC, datetime
-
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel, Field
-from starlette.status import HTTP_200_OK
-
-from app.schemas.constants import __REVISION__, __VERSION__
-from app.schemas.enums import ErrorCodes
-
-
-class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel):
- """
- Base class for all v1 response models.
- """
-
- data: T
- code: ErrorCodes = Field(default=ErrorCodes.OK)
- timestamp: int = Field(
- default_factory=lambda: int(datetime.now(UTC).timestamp()),
- frozen=True,
- examples=[int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())],
- )
- version: int = Field(default=__REVISION__, frozen=True)
-
- def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse:
- return JSONResponse(
- status_code=response_code,
- content=self.model_dump(mode="json"),
- headers=headers,
- )
-
- @classmethod
- def from_message(cls, message: str) -> "WCSResponse[dict]":
- """
- Create a response with a message.
- """
- return WCSResponse[dict](
- data={"message": message},
- code=ErrorCodes.OK,
- )
-
- @classmethod
- def from_dict(cls, data: dict) -> "WCSResponse[dict]":
- """
- Create a response from a dictionary.
- """
- return WCSResponse[dict](
- data=data,
- code=ErrorCodes.OK,
- )
-
-
-class StatusData(BaseModel):
- """
- Status data model for v1 responses.
- """
-
- status: str = Field(default="OK", frozen=True)
- version: str = Field(default=__VERSION__, frozen=True)
-
-
-class StatusResponse(WCSResponse[StatusData]):
- """
- Status response model for v1.
- """
-
- data: StatusData = Field(default=StatusData(), frozen=True)
-
-
-class EmptyResponse(WCSResponse[dict]):
- """
- Empty response model for v1.
- """
-
- data: dict = Field(default={}, frozen=True, json_schema_extra={"example": {}})
+from collections.abc import Mapping
+from datetime import UTC, datetime
+
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+from starlette.status import HTTP_200_OK
+
+from app.schemas.constants import __REVISION__, __VERSION__
+from app.schemas.enums import ErrorCodes
+
+
+class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel):
+ """
+ Base class for all v1 response models.
+ """
+
+ data: T
+ code: ErrorCodes = Field(default=ErrorCodes.OK)
+ timestamp: int = Field(
+ default_factory=lambda: int(datetime.now(UTC).timestamp()),
+ frozen=True,
+ examples=[int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())],
+ )
+ version: int = Field(default=__REVISION__, frozen=True)
+
+ def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse:
+ return JSONResponse(
+ status_code=response_code,
+ content=self.model_dump(mode="json"),
+ headers=headers,
+ )
+
+ @classmethod
+ def from_message(cls, message: str) -> "WCSResponse[dict]":
+ """
+ Create a response with a message.
+ """
+ return WCSResponse[dict](
+ data={"message": message},
+ code=ErrorCodes.OK,
+ )
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "WCSResponse[dict]":
+ """
+ Create a response from a dictionary.
+ """
+ return WCSResponse[dict](
+ data=data,
+ code=ErrorCodes.OK,
+ )
+
+
+class StatusData(BaseModel):
+ """
+ Status data model for v1 responses.
+ """
+
+ status: str = Field(default="OK", frozen=True)
+ version: str = Field(default=__VERSION__, frozen=True)
+
+
+class StatusResponse(WCSResponse[StatusData]):
+ """
+ Status response model for v1.
+ """
+
+ data: StatusData = Field(default=StatusData(), frozen=True)
+
+
+class EmptyResponse(WCSResponse[dict]):
+ """
+ Empty response model for v1.
+ """
+
+ data: dict = Field(default={}, frozen=True, json_schema_extra={"example": {}})
diff --git a/app/utils/redact.py b/app/utils/redact.py
new file mode 100644
index 0000000..2a57204
--- /dev/null
+++ b/app/utils/redact.py
@@ -0,0 +1,15 @@
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+
+def redact_query(query: str) -> str:
+ """Keep parameter names, drop values: manage endpoints carry user tokens in the query."""
+ return urlencode([(k, "REDACTED") for k, _ in parse_qsl(query, keep_blank_values=True)], safe="")
+
+
+def redact_url(value: str) -> str:
+ """``redact_query`` applied to the query part of a URL or request target."""
+ parts = urlsplit(value)
+ return urlunsplit(parts._replace(query=redact_query(parts.query))) if parts.query else value
+
+
+__all__ = ["redact_query", "redact_url"]
diff --git a/deploy/chart/templates/deployment.yaml b/deploy/chart/templates/deployment.yaml
index 0919cd1..a7fab24 100644
--- a/deploy/chart/templates/deployment.yaml
+++ b/deploy/chart/templates/deployment.yaml
@@ -35,6 +35,24 @@ spec:
- secretRef:
name: {{ .Values.existingSecret }}
env:
+ # Downward API first: later entries reference them via $(...).
+ # K8S_POD_NAME also identifies the holder of scheduler job locks.
+ - name: K8S_POD_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+ - name: K8S_NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ {{- with .Values.telemetry }}
+ {{- if .otlpEndpoint }}
+ - name: OTEL_EXPORTER_OTLP_ENDPOINT
+ value: {{ .otlpEndpoint | quote }}
+ - name: OTEL_RESOURCE_ATTRIBUTES
+ value: "service.instance.id=$(K8S_POD_NAME),k8s.pod.name=$(K8S_POD_NAME),k8s.namespace.name=$(K8S_NAMESPACE){{ with .environment }},deployment.environment.name={{ . }}{{ end }}"
+ {{- end }}
+ {{- end }}
- name: POSTGRES_HOST
valueFrom:
secretKeyRef:
diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml
index 4843b52..ec32acc 100644
--- a/deploy/chart/values.yaml
+++ b/deploy/chart/values.yaml
@@ -16,11 +16,22 @@ revisionHistoryLimit: 3
config:
LEVEL: INFO
MAX_IP_RECORDS: "10"
+ # One JSON object per log line (trace_id/span_id included) for the log pipeline.
+ LOG_JSON: "true"
# Extra environment variables appended to the server container
-# (e.g. sentry environment / sample rates / version suffix).
+# (e.g. sentry environment / profiling rate / version suffix).
extraEnv: []
+# OpenTelemetry traces and metrics, pushed over OTLP/HTTP. Off while
+# otlpEndpoint is empty. Each pod reports service.instance.id = its pod name,
+# so replicas never share a metric series.
+telemetry:
+ # e.g. http://otel-collector.monitoring.svc:4318
+ otlpEndpoint: ""
+ # deployment.environment.name resource attribute (e.g. prod, dev).
+ environment: ""
+
# Secret consumed via envFrom; managed outside the chart (SealedSecret etc.).
existingSecret: wynnsource-secrets
diff --git a/pyproject.toml b/pyproject.toml
index 1decf6a..eb93e66 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "wynnsource_server"
-version = "0.1.5"
+version = "0.1.7"
description = "The server component of WynnSource - a Wynncraft Crowdsourcing Mod."
readme = "README.md"
requires-python = ">=3.12, <4.0"
@@ -21,6 +21,13 @@ dependencies = [
"httpx>=0.28.1",
"jsonschema>=4.26.0",
"loguru>=0.7.3",
+ "opentelemetry-exporter-otlp-proto-http>=1.45.1",
+ "opentelemetry-instrumentation-fastapi>=0.66b1",
+ "opentelemetry-instrumentation-httpx>=0.66b1",
+ "opentelemetry-instrumentation-redis>=0.66b1",
+ "opentelemetry-instrumentation-sqlalchemy>=0.66b1",
+ "opentelemetry-instrumentation-system-metrics>=0.66b1",
+ "opentelemetry-sdk>=1.45.1",
"orjson>=3.11.7",
"pydantic-settings>=2.10.1",
"redis>=7.1.1",
@@ -77,6 +84,9 @@ known-first-party = ["app", "tests/*", "wynnsource"]
[tool.pytest.ini_options]
addopts = "-n 4 --cov=app --cov-report xml:cov.xml"
+# Tests import `app` from the repo root; plain `pytest` would otherwise only
+# put each test file's directory on sys.path.
+pythonpath = ["."]
[tool.uv.workspace]
members = ["generated"]
diff --git a/uv.lock b/uv.lock
index 117d8f2..ec6b48b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -70,6 +70,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
]
+[[package]]
+name = "asgiref"
+version = "3.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
+]
+
[[package]]
name = "asyncpg"
version = "0.31.0"
@@ -140,6 +149,137 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
+ { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
+ { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
+ { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
+ { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
+ { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
+ { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
+ { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
+ { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
+ { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
+ { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
+ { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
+ { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
+ { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
+ { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
+ { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
+ { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
+ { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
+ { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
+ { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
+ { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
+ { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
+ { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
+ { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
+ { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
+ { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
+ { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
+ { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
+ { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
+ { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
+ { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
+ { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
+]
+
[[package]]
name = "click"
version = "8.3.1"
@@ -411,6 +551,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" },
]
+[[package]]
+name = "googleapis-common-protos"
+version = "1.75.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" },
+]
+
[[package]]
name = "greenlet"
version = "3.3.2"
@@ -420,7 +572,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
- { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -429,7 +580,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
- { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -438,7 +588,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
- { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -447,7 +596,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
- { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -702,6 +850,236 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" },
]
+[[package]]
+name = "opentelemetry-api"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-http-transport"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" },
+]
+
+[package.optional-dependencies]
+requests = [
+ { name = "requests" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-common"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-sdk" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-common"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-proto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" },
+]
+
+[[package]]
+name = "opentelemetry-exporter-otlp-proto-http"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-exporter-http-transport", extra = ["requests"] },
+ { name = "opentelemetry-exporter-otlp-common" },
+ { name = "opentelemetry-exporter-otlp-proto-common" },
+ { name = "opentelemetry-proto" },
+ { name = "opentelemetry-sdk" },
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "packaging" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-asgi"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asgiref" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "opentelemetry-util-http" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/d9/ff522f5c3e340e9007554923b1a4d2ac451676f8757bafb3d0057f68b5c3/opentelemetry_instrumentation_asgi-0.66b1.tar.gz", hash = "sha256:78cdc5e45e897e16a8dac9d282e8d5bdf9af2d58e1313fa0bdd4a134c6f9dafc", size = 25992, upload-time = "2026-10-06T17:36:14.593Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/ea/10ba99110bf3c9fb736af39c96ca8f3668b988cabb6b59309e058c44461c/opentelemetry_instrumentation_asgi-0.66b1-py3-none-any.whl", hash = "sha256:78b3f9bdf0fa38c65935a2ab46d59e0f9de873a51e0c95b0329f106e2ccb5274", size = 15812, upload-time = "2026-10-06T17:35:17.638Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-fastapi"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-instrumentation-asgi" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "opentelemetry-util-http" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5d/2a/cd4125b7acbea2ed17f1d31b58c184cb0a79fcb5541ceb4de90ffc6d8c01/opentelemetry_instrumentation_fastapi-0.66b1.tar.gz", hash = "sha256:584cf9d2c4417ff8b2d6ff2bc606bfe13c8b3456018bf94f50f2cf658492505b", size = 26634, upload-time = "2026-10-06T17:36:25.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/70/676928d537978acc7bff2ac8657bd0836ba608ffc8f65455238f1fa2bd0f/opentelemetry_instrumentation_fastapi-0.66b1-py3-none-any.whl", hash = "sha256:97f8ac8fd7537517f9e6988bd0aca04bfa5aad564bcd46c245530739e2be72d1", size = 14216, upload-time = "2026-10-06T17:35:32.827Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-httpx"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "opentelemetry-util-http" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/50/41544799b043d14fdfa6fe62fa2518eaba22793fce03e9abde930b92e671/opentelemetry_instrumentation_httpx-0.66b1.tar.gz", hash = "sha256:5865a72c68098c85955a271ab8744b480a36e3ee492d35b8cadb93c7c4dbb618", size = 26637, upload-time = "2026-10-06T17:36:27.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/c6/e5682b1bfb320b32505e88255c34ae1e99fe9fb220cc465c244e91967eac/opentelemetry_instrumentation_httpx-0.66b1-py3-none-any.whl", hash = "sha256:0342a4002c6dbc6c4bf22cc7e698f50f5c8b77f63325c6f40c94ab87e016bf4d", size = 17723, upload-time = "2026-10-06T17:35:36.501Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-redis"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/f7/8a7f4d2178a79b4cca971836491d75884b8f2b3fcd0dc9a3d521ca65e7bd/opentelemetry_instrumentation_redis-0.66b1.tar.gz", hash = "sha256:d6cc6aa473e23692ae1be9011e2cb3ae5894376158533a71f9710e1fb0906dc7", size = 18060, upload-time = "2026-10-06T17:36:36.289Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/22/19286da994ae57c78ecc3f4bd692855ccedc54cd9b05ce56592ee28964c3/opentelemetry_instrumentation_redis-0.66b1-py3-none-any.whl", hash = "sha256:4e36651d553e63c8e8f4068d8a2362ae35ac5c9bbef53c27a0fcb96d9092dbb1", size = 14885, upload-time = "2026-10-06T17:35:50.394Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-sqlalchemy"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "packaging" },
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/3e/d69fb08dacc4c248daedf7357732ddca7a0aaff7072a55e311d3da3ea51c/opentelemetry_instrumentation_sqlalchemy-0.66b1.tar.gz", hash = "sha256:a10043953fcba71911bf29a024f8cc337260c1ef0b4fc844b96cae0de0947baa", size = 18181, upload-time = "2026-10-06T17:36:38.111Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/05/f8cff0c68a7f9ab8fab01904be5a49c5214435109c2a6b6d173a974c10d9/opentelemetry_instrumentation_sqlalchemy-0.66b1-py3-none-any.whl", hash = "sha256:aa30b10d880d7e91cf94b23a92ac85cec09ffddd8f0d40256d7c510e3dd33971", size = 14425, upload-time = "2026-10-06T17:35:53.436Z" },
+]
+
+[[package]]
+name = "opentelemetry-instrumentation-system-metrics"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-instrumentation" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "psutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d6/81/ab8cabaf45c5248fa88a0d0d3acea4483c105d76cd6c849654ebdab16f92/opentelemetry_instrumentation_system_metrics-0.66b1.tar.gz", hash = "sha256:b0fccbc1aecf35c182431a035ec4809b8f9607eec692243cf70f23b3bda51231", size = 17207, upload-time = "2026-10-06T17:36:40.518Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/fd/ea072c512d65ba4054b02c8b7080cf1b362761d4727ee6c1f36dce53328d/opentelemetry_instrumentation_system_metrics-0.66b1-py3-none-any.whl", hash = "sha256:0654b41eb6db3c2f9ad9979487afcc6f461e5b35df282f0a11d0f465e906d967", size = 13913, upload-time = "2026-10-06T17:35:57.01Z" },
+]
+
+[[package]]
+name = "opentelemetry-proto"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" },
+]
+
+[[package]]
+name = "opentelemetry-sdk"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" },
+]
+
+[[package]]
+name = "opentelemetry-semantic-conventions"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "opentelemetry-api" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" },
+]
+
+[[package]]
+name = "opentelemetry-util-http"
+version = "0.66b1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7c/b5/df4b61da899f6ebdffdbdf0c8b0f3189ee57151694ccd5b7d50ee2906241/opentelemetry_util_http-0.66b1.tar.gz", hash = "sha256:047dea1a628031f857a5a32261dc0e955bc162d39993ed1cffb8f2cff5ba8a62", size = 11586, upload-time = "2026-10-06T17:36:46.572Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/9b/c77ecaea79ba0de1a11e7f06a7f5eea7043ec23f1860dcf5f03536698e4c/opentelemetry_util_http-0.66b1-py3-none-any.whl", hash = "sha256:8f443d7abcaf29c4a07b373bbd31b5b39132c0ed3c27d015a59dc0323d5b1c58", size = 8387, upload-time = "2026-10-06T17:36:06.984Z" },
+]
+
[[package]]
name = "orjson"
version = "3.11.7"
@@ -788,6 +1166,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -1045,6 +1451,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
[[package]]
name = "rich"
version = "14.3.3"
@@ -1587,6 +2008,81 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]
+[[package]]
+name = "wrapt"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" },
+ { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" },
+ { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" },
+ { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" },
+ { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" },
+ { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" },
+ { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" },
+ { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" },
+ { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" },
+ { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" },
+ { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" },
+ { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" },
+ { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" },
+ { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" },
+ { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" },
+ { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" },
+ { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" },
+ { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" },
+ { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" },
+]
+
[[package]]
name = "wynnsource-proto"
version = "0.1.0"
@@ -1604,7 +2100,7 @@ requires-dist = [
[[package]]
name = "wynnsource-server"
-version = "0.1.5"
+version = "0.1.7"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
@@ -1614,6 +2110,13 @@ dependencies = [
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "loguru" },
+ { name = "opentelemetry-exporter-otlp-proto-http" },
+ { name = "opentelemetry-instrumentation-fastapi" },
+ { name = "opentelemetry-instrumentation-httpx" },
+ { name = "opentelemetry-instrumentation-redis" },
+ { name = "opentelemetry-instrumentation-sqlalchemy" },
+ { name = "opentelemetry-instrumentation-system-metrics" },
+ { name = "opentelemetry-sdk" },
{ name = "orjson" },
{ name = "pydantic-settings" },
{ name = "redis" },
@@ -1643,6 +2146,13 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", specifier = ">=4.26.0" },
{ name = "loguru", specifier = ">=0.7.3" },
+ { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.45.1" },
+ { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.66b1" },
+ { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.66b1" },
+ { name = "opentelemetry-instrumentation-redis", specifier = ">=0.66b1" },
+ { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.66b1" },
+ { name = "opentelemetry-instrumentation-system-metrics", specifier = ">=0.66b1" },
+ { name = "opentelemetry-sdk", specifier = ">=1.45.1" },
{ name = "orjson", specifier = ">=3.11.7" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "redis", specifier = ">=7.1.1" },