feat(observability): backfill logging for upload, auth, and domain services

Why:
- resolve_auth_context() runs on every authenticated request and logged
  nothing; four distinct rejection reasons (malformed/unknown/inactive/
  expired key, inactive tenant) were all invisible.
- The domain-allowlist rejection in upload_source_file() happens before any
  ingestion_jobs row exists, so it wasn't covered by the job-level
  ingestion.job.failed event either -- a rejected upload left zero trace.
- Four of upload_source_file()'s five failure branches (parse_failed,
  chunk_limit_exceeded, embedding_failed, index_failed) called
  _mark_job_failed(), which wrote to Postgres but never logged; only
  storage_failed and timeout had an ad-hoc logger.warning duplicated at their
  own call sites.

Changes:
- auth/service.py: auth.succeeded / auth.failed (with a reason field per
  rejection type), matching ADR-0011's own event catalog.
- domains/service.py: domain.rejected on the allowlist check;
  domain.created / domain.updated / domain.status_changed on the three
  mutations.
- files/upload.py: centralized failure logging inside _mark_job_failed
  (every failure branch already calls it, so logging there once closes all
  five branches instead of duplicating a log call at each site) as
  ingestion.job.failed; added ingestion.job.started; renamed the ad-hoc
  files.upload.succeeded to ingestion.job.completed for catalog consistency.

Impact:
- None to request/response behavior -- log events only.
This commit is contained in:
Ali Zarinkolah
2026-08-20 19:24:39 +03:30
parent ac779dec7e
commit 012b44d5f2
6 changed files with 595 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ must run with no Postgres session held open at all.
from datetime import UTC, datetime
import structlog
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.auth.context import AuthContext
@@ -16,28 +17,66 @@ from src.application.auth.keys import parse_api_key, verify_secret
from src.infrastructure.postgres.repositories import api_keys as api_keys_repo
from src.infrastructure.postgres.repositories import tenants as tenants_repo
logger = structlog.get_logger(__name__)
async def resolve_auth_context(
sessionmaker: async_sessionmaker[AsyncSession], bearer_token: str
) -> AuthContext:
"""Resolve a bearer token, logging the outcome either way (ADR-0011).
This runs on every authenticated request, so `auth.failed` is the one
event most likely to matter first when diagnosing a client integration
issue -- and the reason string alone (never logged; it can echo back
attacker-supplied key material) is not enough to tell a malformed token
apart from a revoked one without this.
"""
parsed = parse_api_key(bearer_token)
if parsed is None:
logger.warning("auth.failed", reason="malformed_key")
raise InvalidApiKeyError("malformed API key")
key_prefix, secret = parsed
async with sessionmaker() as session:
api_key = await api_keys_repo.get_by_prefix(session, key_prefix)
if api_key is None or not verify_secret(secret, api_key.key_hash):
logger.warning("auth.failed", reason="unknown_key", key_prefix=key_prefix)
raise InvalidApiKeyError("unknown API key")
if api_key.status != "active":
logger.warning(
"auth.failed",
reason="key_inactive",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
key_status=api_key.status,
)
raise InvalidApiKeyError(f"API key is {api_key.status}")
if api_key.expires_at is not None and api_key.expires_at <= datetime.now(UTC):
logger.warning(
"auth.failed",
reason="key_expired",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
)
raise InvalidApiKeyError("API key has expired")
tenant = await tenants_repo.get_by_id(session, api_key.tenant_id)
if tenant is None or tenant.status != "active":
logger.warning(
"auth.failed",
reason="tenant_inactive",
key_prefix=key_prefix,
api_key_id=str(api_key.id),
tenant_id=str(api_key.tenant_id),
)
raise TenantInactiveError("tenant is not active")
logger.info(
"auth.succeeded",
tenant_id=str(tenant.id),
api_key_id=str(api_key.id),
actor_type=api_key.actor_type,
)
return AuthContext(
tenant_id=tenant.id,
tenant_slug=tenant.slug,

View File

@@ -15,6 +15,7 @@ a request body (ADR-0002).
import uuid
import structlog
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.domains.errors import DomainAlreadyExistsError, UnknownDomainError
@@ -22,6 +23,8 @@ from src.application.domains.models import DomainResult
from src.infrastructure.postgres.models.tenant_domain import TenantDomain
from src.infrastructure.postgres.repositories import tenant_domains as repo
logger = structlog.get_logger(__name__)
async def _flush_and_refresh(session: AsyncSession, tenant_domain: TenantDomain) -> None:
"""Materialize server-generated columns before the row leaves the session.
@@ -55,14 +58,25 @@ async def ensure_domain_allowed(
Takes a session rather than a sessionmaker: the upload path calls this
inside its existing txn A, so the check costs no extra connection and
cannot pass and then go stale before the row is written.
Logs the rejection here rather than at the call site: this runs before any
`ingestion_jobs` row exists, so `upload_source_file`'s job-level
`ingestion.job.failed` event (ADR-0011) never fires for it -- without a log
here, a rejected upload would leave no operational trace at all.
"""
tenant_domain = await repo.get(session, tenant_id=tenant_id, domain=domain)
if tenant_domain is None:
logger.warning(
"domain.rejected", tenant_id=str(tenant_id), domain=domain, reason="unregistered"
)
raise UnknownDomainError(
f"domain '{domain}' is not registered for this tenant; "
f"create it via POST /v1/domains before uploading to it"
)
if tenant_domain.status != "active":
logger.warning(
"domain.rejected", tenant_id=str(tenant_id), domain=domain, reason="disabled"
)
raise UnknownDomainError(f"domain '{domain}' is disabled for this tenant")
@@ -98,7 +112,9 @@ async def create_domain(
metadata=metadata,
)
await session.commit()
return _to_result(created)
logger.info("domain.created", tenant_id=str(tenant_id), domain=domain)
return _to_result(created)
async def update_domain(
@@ -116,7 +132,10 @@ async def update_domain(
repo.update_display_name(found, display_name=display_name)
await _flush_and_refresh(session, found)
await session.commit()
return _to_result(found)
result = _to_result(found)
logger.info("domain.updated", tenant_id=str(tenant_id), domain=domain)
return result
async def set_domain_status(
@@ -139,4 +158,7 @@ async def set_domain_status(
repo.set_status(found, status=status)
await _flush_and_refresh(session, found)
await session.commit()
return _to_result(found)
result = _to_result(found)
logger.info("domain.status_changed", tenant_id=str(tenant_id), domain=domain, status=status)
return result

View File

@@ -68,6 +68,16 @@ async def _mark_job_failed(
error_code: str,
error_message: str,
) -> None:
"""Write the terminal `failed` job row and emit its log event together.
Every failure branch below calls this, so logging here once closes every
branch at once rather than duplicating a `logger.warning` at each call
site (CLAUDE.md, "prefer deep modules") -- previously only
`storage_upload_failed` and `timeout` did that ad hoc, and
`parse_failed`/`chunk_limit_exceeded`/`embedding_failed`/`index_failed`
logged nothing at all: visible in `ingestion_job_events` but invisible to
log-based alerting (ADR-0011).
"""
async with sessionmaker() as session:
job = await jobs_repo.mark_terminal(
session,
@@ -88,6 +98,14 @@ async def _mark_job_failed(
)
await session.commit()
logger.warning(
"ingestion.job.failed",
tenant_id=str(tenant_id),
ingestion_job_id=str(ingestion_job_id),
error_code=error_code,
error_message=error_message,
)
async def upload_source_file(
*,
@@ -191,6 +209,15 @@ async def upload_source_file(
await session.commit()
ingestion_job_id = job.id
logger.info(
"ingestion.job.started",
tenant_id=str(auth.tenant_id),
ingestion_job_id=str(ingestion_job_id),
file_id=str(source_file_id),
domain=domain,
source_type=validated.source_type,
)
# Phase 2: no Postgres session open across this work (ADR-0017),
# bounded end-to-end by INGESTION_TIMEOUT_SECONDS.
try:
@@ -200,12 +227,6 @@ async def upload_source_file(
key=object_key, data=data, content_type=validated.content_type
)
except Exception as exc:
logger.warning(
"files.upload.storage_failed",
tenant_id=str(auth.tenant_id),
file_id=str(source_file_id),
ingestion_job_id=str(ingestion_job_id),
)
await _mark_job_failed(
sessionmaker,
tenant_id=auth.tenant_id,
@@ -288,12 +309,6 @@ async def upload_source_file(
)
raise
except TimeoutError:
logger.warning(
"files.upload.timeout",
tenant_id=str(auth.tenant_id),
file_id=str(source_file_id),
ingestion_job_id=str(ingestion_job_id),
)
await _mark_job_failed(
sessionmaker,
tenant_id=auth.tenant_id,
@@ -334,11 +349,13 @@ async def upload_source_file(
await session.commit()
logger.info(
"files.upload.succeeded",
"ingestion.job.completed",
tenant_id=str(auth.tenant_id),
file_id=str(source_file_id),
ingestion_job_id=str(ingestion_job_id),
points_indexed=indexed.points_upserted,
file_id=str(source_file_id),
chunks_parsed=len(chunks),
points_upserted=indexed.points_upserted,
points_soft_deleted=indexed.points_soft_deleted,
)
return UploadResult(
file_id=source_file_id,