Files
chatbot_v3/src/bootstrap/lifespan.py
Ali Zarinkolah 9e8987968c feat(observability): add dual local logging sinks and static environment context
Why:
- Wanted human-readable console output while developing locally, without
  losing a machine-parseable log for later grepping/parsing. A single
  renderer chosen by a flag can't do both at once.
- ADR-0011 had no way to correlate an issue with a specific deployment
  (build/region/instance) independent of any one request.

Changes:
- configure_logging() now builds two independent handlers: console (always
  on, colored unless LOG_JSON_FORMAT=true) and an optional rotating JSON file
  (LOG_FILE_PATH, unset by default) -- the same structlog event fans out to
  both, so call sites are unaffected.
- A static structlog processor binds env/service_version onto every event.
  Deliberately not a contextvar: RequestIdMiddleware's clear_contextvars()
  would wipe a value bound there before the first request.
- New settings: APP_SERVICE_VERSION, LOG_FILE_PATH/LOG_FILE_MAX_BYTES/
  LOG_FILE_BACKUP_COUNT.
- ADR-0011 amended with both decisions ("console and file are independent
  sinks locally"; "bind process-level environment context once at startup").

Impact:
- configure_logging() signature changed to (logging_settings, app_settings);
  both call sites (lifespan, qdrant_bootstrap CLI) updated.
2026-08-20 19:20:27 +03:30

178 lines
7.4 KiB
Python

from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import httpx
import structlog
from anyio import CapacityLimiter, Semaphore, to_thread
from fastapi import FastAPI
from src.application.ingestion import get_encoder
from src.application.ports.embedding import DenseEmbedder
from src.bootstrap.dependencies import AppResources
from src.config import Settings
from src.infrastructure.embedding.bm25 import Bm25SparseEmbedder
from src.infrastructure.embedding.openai_compatible import (
OpenAICompatibleEmbedder,
is_ollama_base_url,
)
from src.infrastructure.minio.client import create_client as create_minio_client
from src.infrastructure.minio.storage import MinioObjectStorage
from src.infrastructure.observability.logging import configure_logging
from src.infrastructure.postgres.database import create_engine, create_sessionmaker
from src.infrastructure.qdrant.client import create_client as create_qdrant_client
from src.infrastructure.qdrant.points import QdrantPointStorage
logger = structlog.get_logger(__name__)
def _auth_headers(api_key: str | None) -> dict[str, str]:
"""Bearer header, or none at all when no key is configured.
Sending an empty `Bearer ` is worse than sending nothing: some gateways
treat a malformed credential as an auth failure rather than as anonymous.
"""
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
async def _warm_dense_embedders(embedders: Sequence[DenseEmbedder]) -> None:
"""Force each dense model to load before the first upload needs it.
Same rationale as the tiktoken warm-up above, but with the opposite
failure policy. A self-hosted embedder that has unloaded the model takes
minutes to serve its first request — longer than
`INGESTION_TIMEOUT_SECONDS` — so paying that once at boot keeps it off a
user's upload. Unlike the tokenizer this is best-effort: an embedder that
is merely *down* must not stop the process from booting and reporting its
own health, and `/readyz` is where that condition belongs.
"""
for embedder in embedders:
try:
await embedder.embed_batch(["warmup"])
logger.info("lifespan.embedder.warmed", embedder=embedder.name)
except Exception:
logger.warning("lifespan.embedder.warm_failed", embedder=embedder.name, exc_info=True)
def create_lifespan(
settings: Settings | None = None,
) -> Callable[[FastAPI], AbstractAsyncContextManager[None, bool | None]]:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
resolved_settings = settings or Settings()
configure_logging(resolved_settings.logging, resolved_settings.app)
# tiktoken fetches its vocabulary over the network on first use, so warm
# it here: a missing vocabulary should fail the process at boot, not the
# first upload. Blocking, hence the thread.
await to_thread.run_sync(get_encoder, resolved_settings.chunking.encoding_name)
logger.info(
"lifespan.tokenizer.loaded",
encoding=resolved_settings.chunking.encoding_name,
)
db_engine = create_engine(resolved_settings.postgres)
db_sessionmaker = create_sessionmaker(db_engine)
logger.info("lifespan.postgres.engine.created")
minio_client = create_minio_client(resolved_settings.minio)
logger.info("lifespan.minio.client.created")
qdrant_client = create_qdrant_client(resolved_settings.qdrant)
# No collection DDL here: `ensure_chunks_collection` is a deployment
# step (`python -m src.cli.qdrant_bootstrap`), for the same reason
# ADR-0009 keeps Alembic out of startup and ADR-0012 makes LangGraph's
# `.setup()` a deployment step.
point_storage = QdrantPointStorage(
qdrant_client, collection=resolved_settings.qdrant.collection
)
logger.info("lifespan.qdrant.client.created")
nomic_settings = resolved_settings.embedding.nomic
nomic_http_client = httpx.AsyncClient(
base_url=nomic_settings.base_url,
timeout=nomic_settings.timeout_seconds,
headers=_auth_headers(nomic_settings.api_key),
)
openai_settings = resolved_settings.embedding.openai
openai_http_client = httpx.AsyncClient(
base_url=openai_settings.base_url,
timeout=openai_settings.timeout_seconds,
headers=_auth_headers(openai_settings.api_key),
)
dense_embedders = (
OpenAICompatibleEmbedder(
nomic_http_client,
name="dense_nomic",
model=nomic_settings.model,
document_prefix=nomic_settings.document_prefix,
keep_alive=(
nomic_settings.keep_alive
if is_ollama_base_url(nomic_settings.base_url)
else None
),
),
OpenAICompatibleEmbedder(
openai_http_client,
name="dense_openai",
model=openai_settings.model,
dimensions=openai_settings.dimensions,
document_prefix=openai_settings.document_prefix,
),
)
sparse_embedder = Bm25SparseEmbedder(resolved_settings.embedding.sparse)
logger.info("lifespan.embedders.created")
await _warm_dense_embedders(dense_embedders)
# Bounds how many ingestions run in this process at once (ADR-0017);
# a distinct resource from ingestion_limiter, which bounds threads
# spent on blocking work within a single ingestion.
ingestion_concurrency_limiter = Semaphore(resolved_settings.ingestion.max_concurrency)
# Bounds threads spent on blocking ingestion work (parsing, chunking,
# hashing, the sync minio SDK) so it cannot exhaust Starlette's own
# thread pool (ADR-0017).
ingestion_limiter = CapacityLimiter(resolved_settings.ingestion.thread_pool_size)
object_storage = MinioObjectStorage(
minio_client, bucket=resolved_settings.minio.bucket, limiter=ingestion_limiter
)
app.state.resources = AppResources(
settings=resolved_settings,
db_engine=db_engine,
db_sessionmaker=db_sessionmaker,
minio_client=minio_client,
qdrant_client=qdrant_client,
object_storage=object_storage,
point_storage=point_storage,
ingestion_limiter=ingestion_limiter,
dense_embedders=dense_embedders,
sparse_embedder=sparse_embedder,
ingestion_concurrency_limiter=ingestion_concurrency_limiter,
)
try:
yield
finally:
try:
await db_engine.dispose()
except Exception:
logger.exception("lifespan.postgres.dispose.failed")
try:
await qdrant_client.close()
except Exception:
logger.exception("lifespan.qdrant.close.failed")
try:
await nomic_http_client.aclose()
except Exception:
logger.exception("lifespan.embedding.nomic_client.close.failed")
try:
await openai_http_client.aclose()
except Exception:
logger.exception("lifespan.embedding.openai_client.close.failed")
return lifespan