Compare commits

..

No commits in common. "c4b22be773d061e22effce37e4407417aff5042e" and "2d5c2b50c0036632ed0b0e7d62deab38e39f2c9d" have entirely different histories.

53 changed files with 1666 additions and 16884 deletions

View file

@ -48,16 +48,22 @@ REFUND_FULL_USAGE_THRESHOLD=0.20 # ≤20% consumed + within window ⇒ full ref
# --- Observability (leave empty to disable) --- # --- Observability (leave empty to disable) ---
SENTRY_DSN= SENTRY_DSN=
# OTEL_EXPORTER_OTLP_ENDPOINT is no longer used. The application does not push # OTLP endpoint for traces/metrics/logs.
# OTLP; Vector/OpenObserve collects logs/metrics passively from stdout and # profile: observer → http://otel-collector:4318 (OTLP/HTTP via collector)
# /metrics endpoints. Remove this line from existing .env files. # profile: obs → configure your own otel-collector/tempo, or leave empty
# OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT=
# OTEL_EXPORTER_OTLP_HEADERS= # Auth header for OpenObserve. Used by the otel-collector to forward telemetry.
# Generate with: echo -n 'user:pass' | base64
# Default value is for root@example.com:Complexpass#123.
OPENOBSERVE_AUTH_TOKEN=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=
# Only needed if sending OTLP directly to OpenObserve (without collector).
# The collector uses OPENOBSERVE_AUTH_TOKEN instead.
OTEL_EXPORTER_OTLP_HEADERS=
OTEL_SERVICE_NAME=contract-check
# --- OpenObserve (profile: observer) --- # --- OpenObserve (profile: observer) ---
# Vector (profile `observer`) authenticates to OpenObserve with these
# credentials, so keep them in sync with the OpenObserve root user below.
OPENOBSERVE_PORT=5080 OPENOBSERVE_PORT=5080
OPENOBSERVE_GRPC_PORT=5081
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123 OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
# disk | s3 (s3 requires ZO_S3_* env vars; see OpenObserve docs) # disk | s3 (s3 requires ZO_S3_* env vars; see OpenObserve docs)
@ -71,6 +77,11 @@ OPENOBSERVE_MEMORY_LIMIT=2G
OPENOBSERVE_CPU_RESERVATION=0.5 OPENOBSERVE_CPU_RESERVATION=0.5
OPENOBSERVE_MEMORY_RESERVATION=512M OPENOBSERVE_MEMORY_RESERVATION=512M
# --- OTel Collector (profile: observer) ---
OTEL_COLLECTOR_OTLP_GRPC_PORT=4317
OTEL_COLLECTOR_OTLP_HTTP_PORT=4318
OTEL_COLLECTOR_METRICS_PORT=8889
# --- Grafana / Loki (profile: obs) --- # --- Grafana / Loki (profile: obs) ---
GRAFANA_PORT=3000 GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_USER=admin
@ -109,16 +120,9 @@ CHUNK_SIZE_CHARS=10000
# --- API (FastAPI) --- # --- API (FastAPI) ---
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8000
# Bind the /metrics endpoint to a host-local address by default so it is not
# exposed on the public interface. Set to 0.0.0.0 only when an external scraper
# (e.g. a separate monitoring host) legitimately needs access.
API_METRICS_BIND_HOST=127.0.0.1
API_METRICS_PORT=9100 API_METRICS_PORT=9100
B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency
# Bearer token protecting /metrics. Generate a strong secret for production and # Bearer token protecting /metrics. Leave empty to keep the endpoint open (default).
# share it with authorized scrapers. Leaving this empty keeps /metrics open, which
# is convenient for local development but should not be used in production.
# Generate with: openssl rand -hex 32
METRICS_BEARER_TOKEN= METRICS_BEARER_TOKEN=
CORS_ORIGINS= # comma-separated, future web SPA CORS_ORIGINS= # comma-separated, future web SPA

View file

@ -60,37 +60,33 @@ jobs:
run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=50 -m "not integration" tests/unit run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=50 -m "not integration" tests/unit
# Integration tests. # Integration tests.
test-integration: # test-integration:
name: Integration tests # name: Integration tests
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- uses: actions/checkout@v4 # - uses: actions/checkout@v4
- name: Setup uv # - name: Setup uv
uses: astral-sh/setup-uv@v8.3.2 # uses: astral-sh/setup-uv@v8.3.2
with: # with:
enable-cache: true # enable-cache: true
cache-dependency-glob: uv.lock # cache-dependency-glob: uv.lock
- name: Install Python # - name: Install Python
run: uv python install # run: uv python install
- name: Sync dev dependencies # - name: Sync dev dependencies
run: uv sync --group dev --frozen # run: uv sync --group dev --frozen
# No --wait: minio-init-test is a one-shot container that exits (0) after # - name: Start infrastructure (postgres/redis/rabbitmq/minio)
# creating the bucket, and --wait treats any exited container as failure. # run: docker compose up -d --wait
# Health polling is done by the `infra` fixture in
# tests/integration/conftest.py; this step just pre-pulls the images.
- name: Start test infrastructure (postgres/redis/rabbitmq/minio)
run: docker compose -p contract-check-test -f docker-compose.test.yml up -d
- name: Run integration tests # - name: Run integration tests
run: uv run pytest -m integration # run: uv run pytest -m integration
- name: Teardown infrastructure # - name: Teardown infrastructure
if: always() # if: always()
run: docker compose -p contract-check-test -f docker-compose.test.yml down -v # run: docker compose down -v
# Build and push service images on pushes to main. Set these repository secrets: # Build and push service images on pushes to main. Set these repository secrets:
# REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io) # REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io)

View file

@ -15,7 +15,6 @@ repos:
exclude: ^deploy/vps/docker-compose\.override\.example\.yml$ exclude: ^deploy/vps/docker-compose\.override\.example\.yml$
- id: check-toml - id: check-toml
- id: check-added-large-files - id: check-added-large-files
exclude: ^docs/
- id: check-merge-conflict - id: check-merge-conflict
- id: mixed-line-ending - id: mixed-line-ending
args: [--fix=lf] args: [--fix=lf]

View file

@ -1 +1 @@
3.14 3.13

View file

@ -132,13 +132,13 @@ obs-url: ## Print Grafana URL and default credentials
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Docker: OpenObserve collector stack # Docker: OpenObserve collector stack
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
observer-up: ## Start OpenObserve + Vector containers observer-up: ## Start OpenObserve + otel-collector containers
docker compose --profile observer up -d --build --remove-orphans docker compose --profile observer up -d --build --remove-orphans
observer-down: ## Stop OpenObserve + Vector containers observer-down: ## Stop OpenObserve + otel-collector containers
docker compose --profile observer down docker compose --profile observer down
observer-logs: ## Tail OpenObserve + Vector containers logs observer-logs: ## Tail OpenObserve + otel-collector containers logs
docker compose --profile observer logs -f docker compose --profile observer logs -f
obs-reset: ## Reset Grafana and Loki volumes (wipes dashboards/logs data) obs-reset: ## Reset Grafana and Loki volumes (wipes dashboards/logs data)

View file

@ -143,12 +143,11 @@ docker compose --profile bot up -d --build # + bot (можно запус
- `services` — api + worker-ы (без бота). - `services` — api + worker-ы (без бота).
- `bot` — Telegram-бот; можно поднять на этом же хосте или на отдельном сервере (`docs/DEPLOY.md` §14). - `bot` — Telegram-бот; можно поднять на этом же хосте или на отдельном сервере (`docs/DEPLOY.md` §14).
- `edge` — Nginx + certbot (`deploy/nginx/`, `docs/DEPLOY.md` §13). - `edge` — Nginx + certbot (`deploy/nginx/`, `docs/DEPLOY.md` §13).
- `obs` / `observer` — observability (Grafana/Loki/Prometheus или OpenObserve + Vector). - `obs` / `observer` — observability (Grafana/Loki/Prometheus или OpenObserve + OTel collector).
Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`, Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`,
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100` RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`,
(по умолчанию только на loopback), OpenObserve UI `5080`, edge `80`/`443`. worker metrics: extract `9101`, analyze `9102`, notify `9103`, prescreen `9104`, billing `9105`, edge `80`/`443`.
Метрики worker-ов больше не публикуются на хост; их скрейпит Vector внутри сети compose.
## API (кратко) ## API (кратко)

View file

@ -0,0 +1,70 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Scrape the Prometheus /metrics endpoints exposed by api and workers.
prometheus:
config:
scrape_configs:
- job_name: contract-check
static_configs:
- targets:
- api:9100
- worker-extract:9101
- worker-analyze:9102
- worker-prescreen:9104
- worker-billing:9105
- worker-notify:9103
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
# Forward logs and traces to the OpenObserve OTLP/HTTP endpoint.
# The /openobserve prefix matches ZO_BASE_URI in docker-compose.yml.
otlphttp/openobserve:
endpoint: http://openobserve:5080/openobserve/api/default
headers:
Authorization: Basic ${env:OPENOBSERVE_AUTH_TOKEN}
tls:
insecure: true
# OpenObserve metrics are ingested via Prometheus remote-write.
prometheusremotewrite/openobserve:
endpoint: http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write
headers:
Authorization: Basic ${env:OPENOBSERVE_AUTH_TOKEN}
tls:
insecure: true
# Echo pipeline data to the collector's own logs (useful for debugging).
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/openobserve]
metrics:
receivers: [otlp, prometheus]
processors: [batch]
exporters: [prometheusremotewrite/openobserve]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/openobserve]
# Collector's own telemetry (optional, useful for debugging).
telemetry:
logs:
level: info
metrics:
level: detailed

View file

@ -1,109 +0,0 @@
# Vector configuration for the `observer` profile (passive observability).
#
# Vector reads container logs from the local Docker socket and scrapes service
# /metrics endpoints inside the compose network, then forwards logs and metrics
# to OpenObserve. The application itself does not push telemetry.
# -----------------------------------------------------------------------------
# Sources
# -----------------------------------------------------------------------------
sources:
docker_logs:
type: docker_logs
# Read from the local Docker socket mounted by compose.
docker_host: unix:///var/run/docker.sock
include_labels:
- com.docker.compose.project=dealdocumentscreening
service_metrics:
type: prometheus_scrape
endpoints:
- http://api:8000/metrics
- http://worker-extract:9101/metrics
- http://worker-analyze:9102/metrics
- http://worker-prescreen:9104/metrics
- http://worker-billing:9105/metrics
- http://worker-notify:9103/metrics
scrape_interval_secs: 15
# The token is optional: an empty METRICS_BEARER_TOKEN leaves /metrics open.
authorization:
strategy: bearer
token: "${METRICS_BEARER_TOKEN-}"
# -----------------------------------------------------------------------------
# Transforms
# -----------------------------------------------------------------------------
transforms:
enrich_logs:
type: remap
inputs:
- docker_logs
source: |
# Derive a short service label from the container metadata
# (docker_logs always provides container_name).
.container = .container_name
.service = .container_name
# If the log line is JSON from our structured logger, merge its fields
# without overwriting Vector/container metadata.
raw = .message
if is_string(raw) {
parsed = parse_json(raw) ?? null
if is_object(parsed) {
. = merge!(parsed, .)
service = parsed.service
if service != null {
.service = service
}
correlation_id = parsed.correlation_id
if correlation_id != null {
.correlation_id = correlation_id
}
}
}
# Trim the compose project prefix for a cleaner service label.
if is_string(.service) {
.service = replace!(.service, r'^contract_check-', "")
}
# -----------------------------------------------------------------------------
# Sinks
# -----------------------------------------------------------------------------
sinks:
openobserve_logs:
type: http
inputs:
- enrich_logs
uri: http://openobserve:5080/openobserve/api/default/contract_check/_json
method: post
auth:
strategy: basic
user: "${OPENOBSERVE_ROOT_USER_EMAIL-}"
password: "${OPENOBSERVE_ROOT_USER_PASSWORD-}"
encoding:
codec: json
batch:
max_events: 100
timeout_secs: 1
request:
headers:
Content-Type: application/json
openobserve_metrics:
type: prometheus_remote_write
inputs:
- service_metrics
# OpenObserve answers Vector's healthcheck probe with 405; this is
# harmless — actual remote-write POSTs succeed (200).
endpoint: http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write
auth:
strategy: basic
user: "${OPENOBSERVE_ROOT_USER_EMAIL-}"
password: "${OPENOBSERVE_ROOT_USER_PASSWORD-}"
batch:
max_events: 100
timeout_secs: 1

View file

@ -1,127 +0,0 @@
# Test-only infrastructure for integration tests.
#
# Mirrors the real infra (postgres + redis + rabbitmq + minio) but uses
# dedicated container names and host ports so it can coexist with the
# development stack (`docker-compose.yml`). Nothing in this file runs
# application services — only the data stores required by
# tests/integration/conftest.py.
#
# Usage:
# docker compose -p contract-check-test -f docker-compose.test.yml up -d
# uv run pytest -m integration
# docker compose -p contract-check-test -f docker-compose.test.yml down -v
#
# Do NOT use --wait: minio-init-test is a one-shot container that exits after
# creating the bucket, and --wait treats any exited container as a failure
# (even with exit code 0). Health polling is handled by conftest.py.
#
# The conftest.py fixture `infra` brings this file up automatically before the
# first integration test and tears it down after the session.
services:
postgres-test:
image: postgres:18-alpine
restart: "no"
command:
- "postgres"
- "-c"
- "wal_level=replica"
- "-c"
- "archive_mode=on"
- "-c"
- "archive_command=test ! -f /walarchive/%f && cp %p /walarchive/%f"
environment:
POSTGRES_USER: contract_check
POSTGRES_PASSWORD: contract_check
POSTGRES_DB: contract_check
volumes:
- pgdata-test:/var/lib/postgresql
- pgwal-test:/walarchive
ports:
- "25432:5432"
healthcheck:
test:
- CMD-SHELL
- "pg_isready -U contract_check -d contract_check"
interval: 5s
timeout: 3s
retries: 10
redis-test:
image: redis:8-alpine
restart: "no"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata-test:/data
ports:
- "27379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
rabbitmq-test:
image: rabbitmq:4-management-alpine
restart: "no"
environment:
RABBITMQ_DEFAULT_USER: contract_check
RABBITMQ_DEFAULT_PASS: contract_check
RABBITMQ_DEFAULT_VHOST: /
volumes:
- rabbitmq-test:/var/lib/rabbitmq
ports:
- "6672:5672" # AMQP
- "25672:15672" # management UI (http://localhost:25672)
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
minio-test:
image: minio/minio:latest
restart: "no"
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
volumes:
- minio-test:/data
ports:
- "10000:9000" # S3 API
- "10001:9001" # console (http://localhost:10001)
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
minio-init-test:
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
set -e
mc alias set local http://minio-test:9000 contract_check contract_check
mc mb --ignore-existing local/contract-check-docs-test
mc anonymous set none local/contract-check-docs-test || true
mc ilm rule add --expire-days 7 local/contract-check-docs-test || true
echo "bucket contract-check-docs-test ready"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
restart: "no"
volumes:
pgdata-test:
pgwal-test:
redisdata-test:
rabbitmq-test:
minio-test:

View file

@ -150,9 +150,10 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: api
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
- "${API_METRICS_BIND_HOST:-127.0.0.1}:${API_METRICS_PORT:-9100}:9100" - "${API_METRICS_PORT:-9100}:9100"
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@ -187,6 +188,9 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-extract
ports:
- "9101:9101"
worker-analyze: worker-analyze:
profiles: ["services"] profiles: ["services"]
@ -213,6 +217,9 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-analyze
ports:
- "9102:9102"
worker-prescreen: worker-prescreen:
profiles: ["services"] profiles: ["services"]
@ -239,6 +246,9 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-prescreen
ports:
- "9104:9104"
worker-billing: worker-billing:
profiles: ["services"] profiles: ["services"]
@ -257,6 +267,9 @@ services:
required: false required: false
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
OTEL_SERVICE_NAME: worker-billing
ports:
- "9105:9105"
worker-notify: worker-notify:
profiles: ["services"] profiles: ["services"]
@ -279,6 +292,9 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check}
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
S3_ENDPOINT_URL: http://minio:9000 S3_ENDPOINT_URL: http://minio:9000
OTEL_SERVICE_NAME: worker-notify
ports:
- "9103:9103"
# Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17 # Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17
# the bot holds no DB/MQ/S3 credentials — it talks only to the API. # the bot holds no DB/MQ/S3 credentials — it talks only to the API.
@ -383,16 +399,16 @@ services:
# ── OBSERVABILITY ────────────────────────────────────────────────────────── # ── OBSERVABILITY ──────────────────────────────────────────────────────────
# Two observability stacks are provided; activate exactly one profile. # Two observability stacks are provided; activate exactly one profile.
# #
# profile: observer → OpenObserve via Vector (lightweight, single binary). # profile: observer → OpenObserve (lightweight, single binary).
# Vector scrapes container logs from the Docker socket # Use for dev/homelab or when hardware is tight.
# and scrapes service /metrics endpoints; application
# code does not push telemetry.
# profile: obs → Grafana + Prometheus + Loki (mature, heavier). # profile: obs → Grafana + Prometheus + Loki (mature, heavier).
# Use for production-grade visibility. # Use for production-grade visibility.
# #
# Vector (profile `observer`) authenticates to OpenObserve with # App services send OTLP traces/metrics/logs to the endpoint configured in
# OPENOBSERVE_ROOT_USER_EMAIL / OPENOBSERVE_ROOT_USER_PASSWORD. No OTLP # OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at otel-collector:4318 for the
# endpoint configuration is needed in the app. # lightweight profile (the app uses OTLP/HTTP exporters). The collector then
# forwards logs/metrics/traces to OpenObserve. For the obs profile you can
# point this at your own otel-collector/tempo or leave it empty.
# ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ─────────────────────────── # ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ───────────────────────────
openobserve: openobserve:
@ -413,7 +429,8 @@ services:
volumes: volumes:
- openobserve-data:/data - openobserve-data:/data
ports: ports:
- "${OPENOBSERVE_PORT:-5080}:5080" # UI + ingestion endpoints - "${OPENOBSERVE_PORT:-5080}:5080" # UI + OTLP/HTTP
- "${OPENOBSERVE_GRPC_PORT:-5081}:5081" # OTLP/gRPC
deploy: deploy:
resources: resources:
limits: limits:
@ -423,18 +440,20 @@ services:
cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}" cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}"
memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}" memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}"
vector: otel-collector:
profiles: ["observer"] profiles: ["observer"]
image: timberio/vector:0.43.0-alpine image: otel/opentelemetry-collector-contrib:latest
container_name: contract_check-vector container_name: contract_check-otel-collector
restart: unless-stopped restart: unless-stopped
command: ["--config", "/etc/otelcol/config.yaml"]
environment: environment:
OPENOBSERVE_ROOT_USER_EMAIL: ${OPENOBSERVE_ROOT_USER_EMAIL:-root@example.com} OPENOBSERVE_AUTH_TOKEN: ${OPENOBSERVE_AUTH_TOKEN}
OPENOBSERVE_ROOT_USER_PASSWORD: ${OPENOBSERVE_ROOT_USER_PASSWORD:-Complexpass#123}
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - ./deploy/observability/otel-collector-config.yaml:/etc/otelcol/config.yaml:ro
- ./deploy/observability/vector-config.yaml:/etc/vector/vector.yaml:ro ports:
- "${OTEL_COLLECTOR_OTLP_GRPC_PORT:-4317}:4317" # OTLP gRPC receiver
- "${OTEL_COLLECTOR_OTLP_HTTP_PORT:-4318}:4318" # OTLP HTTP receiver
- "${OTEL_COLLECTOR_METRICS_PORT:-8889}:8889" # Collector self-metrics
depends_on: depends_on:
openobserve: openobserve:
condition: service_started condition: service_started

View file

@ -13,7 +13,7 @@
> **workers** (extract/OCR, deterministic prescreen, and LLM-analyze, split for > **workers** (extract/OCR, deterministic prescreen, and LLM-analyze, split for
> CPU vs I/O profiles), a Telegram **bot** adapter, backed by > CPU vs I/O profiles), a Telegram **bot** adapter, backed by
> **Postgres + RabbitMQ + MinIO + Redis**, observable via > **Postgres + RabbitMQ + MinIO + Redis**, observable via
> **structlog + Sentry + Prometheus/Grafana + OpenObserve/Vector**, behind > **structlog + Sentry + Prometheus/Grafana + OpenTelemetry**, behind
> **Nginx + certbot**. Monorepo, six fine-tuned Docker images > **Nginx + certbot**. Monorepo, six fine-tuned Docker images
> (`srv/` — api + three workers + bot + prototype), shared `contract_check.core` > (`srv/` — api + three workers + bot + prototype), shared `contract_check.core`
> domain package. Compose now, k8s-ready later. > domain package. Compose now, k8s-ready later.
@ -51,8 +51,7 @@ Additional decisions locked during the architecture questionnaire:
- **Refund policy is a runtime switch** (`REFUND_POLICY=all|infra_only`) with - **Refund policy is a runtime switch** (`REFUND_POLICY=all|infra_only`) with
a failure-class taxonomy; credit refund stays idempotent. a failure-class taxonomy; credit refund stays idempotent.
- **Observability is full**, not logs-only: structlog+correlation_id, Sentry, - **Observability is full**, not logs-only: structlog+correlation_id, Sentry,
Prometheus+Grafana; the lightweight `observer` profile uses Vector+OpenObserve. Prometheus+Grafana, OpenTelemetry.
OpenTelemetry push has been removed in favour of passive collection.
- **Payments (ЮKassa) live** behind a provider port (`core/billing/port.py`), - **Payments (ЮKassa) live** behind a provider port (`core/billing/port.py`),
webhook-driven state machine, plans/subscriptions + credit top-ups, and a webhook-driven state machine, plans/subscriptions + credit top-ups, and a
`worker-billing` scheduler (renewals/expiry/reconciliation). Disabled by `worker-billing` scheduler (renewals/expiry/reconciliation). Disabled by
@ -130,14 +129,14 @@ Control plane:
| Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan | | Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan |
| Report storage | JSONB + markdown column | — | | Report storage | JSONB + markdown column | — |
| Durability | HA-*ready* (quorum queues, WAL archive) | — | | Durability | HA-*ready* (quorum queues, WAL archive) | — |
| Observability | structlog+corr, Sentry, Prom/Grafana, Vector/OpenObserve | docker logs | | Observability | structlog+corr, Sentry, Prom/Grafana, OTel | docker logs |
| LLM | Provider port + Ollama Cloud adapter | direct client | | LLM | Provider port + Ollama Cloud adapter | direct client |
| Refund | Policy switch `all\|infra_only` + failure classes | refund-all | | Refund | Policy switch `all\|infra_only` + failure classes | refund-all |
| Edge | Nginx + certbot | — | | Edge | Nginx + certbot | — |
| Deploy | Compose now, k8s-ready later | — | | Deploy | Compose now, k8s-ready later | — |
| Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark | | Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark |
| Tests | pytest+respx unit + testcontainers integration | — | | Tests | pytest+respx unit + testcontainers integration | — |
| Python | **3.14** | py3.13 | | Python | **3.13** (was 3.14) — wheel availability | py3.14 |
| Landing | Incremental, green per step | — | | Landing | Incremental, green per step | — |
--- ---
@ -168,7 +167,7 @@ DealDocumentScreening/
│ │ └── certbot-init.sh (initial cert + nginx reload) │ │ └── certbot-init.sh (initial cert + nginx reload)
│ └── observability/ │ └── observability/
│ ├── prometheus/prometheus.yml (scrape api + workers :9100..:9105) │ ├── prometheus/prometheus.yml (scrape api + workers :9100..:9105)
│ ├── vector-config.yaml (OpenObserve profile: Vector log/metric collection) │ ├── otel-collector-config.yaml (OpenObserve profile: receiver + forwarder)
│ ├── loki-config.yaml (Grafana/Loki profile) │ ├── loki-config.yaml (Grafana/Loki profile)
│ ├── tempo.yaml (planned — trace storage for Grafana stack) │ ├── tempo.yaml (planned — trace storage for Grafana stack)
│ └── grafana/provisioning/ │ └── grafana/provisioning/
@ -186,7 +185,7 @@ DealDocumentScreening/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── config.py (pydantic-settings: base + per-service, §11) │ │ ├── config.py (pydantic-settings: base + per-service, §11)
│ │ ├── logging.py (structlog JSON + correlation_id contextvar) │ │ ├── logging.py (structlog JSON + correlation_id contextvar)
│ │ ├── telemetry.py (no-op stubs; OTLP push removed) │ │ ├── telemetry.py (OTel SDK init, FastAPI/asyncio instrumentation)
│ │ ├── sentry.py (sentry_sdk init helper) │ │ ├── sentry.py (sentry_sdk init helper)
│ │ ├── metrics.py (prometheus_client registry + counters/hists) │ │ ├── metrics.py (prometheus_client registry + counters/hists)
│ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest) │ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest)
@ -1029,7 +1028,7 @@ change.
### Volumes & restart ### Volumes & restart
- Named volumes for `pgdata`, `rabbitmq`, `minio`, `redis`, `prometheus`, - Named volumes for `pgdata`, `rabbitmq`, `minio`, `redis`, `prometheus`,
`grafana`. Bind-mount only `./deploy` configs and cert dirs. `grafana`, `tempo`. Bind-mount only `./deploy` configs and cert dirs.
- `restart: unless-stopped` on every long-running service. - `restart: unless-stopped` on every long-running service.
- `depends_on: condition: service_healthy` everywhere with real healthchecks - `depends_on: condition: service_healthy` everywhere with real healthchecks
(pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`, (pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`,
@ -1087,11 +1086,12 @@ None of 15 requires touching `core/` application code — only compose/infra.
| `REFUND_WINDOW_DAYS` | `14` | full-refund window (§8a) | | `REFUND_WINDOW_DAYS` | `14` | full-refund window (§8a) |
| `REFUND_FULL_USAGE_THRESHOLD` | `0.20` | usage ratio for full refund | | `REFUND_FULL_USAGE_THRESHOLD` | `0.20` | usage ratio for full refund |
| `SENTRY_DSN` | (empty) | if set, init sentry | | `SENTRY_DSN` | (empty) | if set, init sentry |
| `OPENOBSERVE_ROOT_USER_EMAIL` | `root@example.com` | OpenObserve root user; Vector uses it for Basic auth | | `OTEL_EXPORTER_OTLP_ENDPOINT` | (empty) | OTLP endpoint; with profile `observer` use `http://otel-collector:4318` |
| `OPENOBSERVE_ROOT_USER_PASSWORD` | `Complexpass#123` | OpenObserve root password; Vector uses it for Basic auth | | `OTEL_SERVICE_NAME` | per-service | overridden in each service settings |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | (removed) | no longer used; the application does not push OTLP | | `OPENOBSERVE_AUTH_TOKEN` | default creds base64 | Basic auth token the collector uses to forward to OpenObserve |
| `OTEL_EXPORTER_OTLP_HEADERS` | (removed) | no longer used | | `OTEL_COLLECTOR_OTLP_GRPC_PORT` | `4317` | host port for collector OTLP/gRPC receiver |
| `OTEL_SERVICE_NAME` | (removed) | service name is set by `configure_logging()` and compose labels | | `OTEL_COLLECTOR_OTLP_HTTP_PORT` | `4318` | host port for collector OTLP/HTTP receiver |
| `OTEL_COLLECTOR_METRICS_PORT` | `8889` | host port for collector self-metrics |
| `GRAFANA_PORT` | `3000` | host port for the Grafana UI (profile `obs`) | | `GRAFANA_PORT` | `3000` | host port for the Grafana UI (profile `obs`) |
| `GRAFANA_ADMIN_USER` | `admin` | initial Grafana admin user | | `GRAFANA_ADMIN_USER` | `admin` | initial Grafana admin user |
| `GRAFANA_ADMIN_PASSWORD` | `admin` | initial Grafana admin password | | `GRAFANA_ADMIN_PASSWORD` | `admin` | initial Grafana admin password |
@ -1141,10 +1141,9 @@ None of 15 requires touching `core/` application code — only compose/infra.
|---|---|---| |---|---|---|
| `API_HOST` | `0.0.0.0` | | | `API_HOST` | `0.0.0.0` | |
| `API_PORT` | `8000` | | | `API_PORT` | `8000` | |
| `API_METRICS_BIND_HOST` | `127.0.0.1` | host interface the API /metrics port is bound to (compose only) | | `API_METRICS_PORT` | `9100` | |
| `API_METRICS_PORT` | `9100` | host port the API /metrics endpoint is published on (compose only; default loopback) |
| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` | | `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` | per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps` |
| `METRICS_BEARER_TOKEN` | (empty) | Bearer token protecting `/metrics`; empty leaves the endpoint open, so set a secret in production | | `METRICS_BEARER_TOKEN` | (empty) | Bearer token protecting `/metrics`; empty leaves the endpoint open |
| `CORS_ORIGINS` | (empty, future web) | | | `CORS_ORIGINS` | (empty, future web) | |
### Auth (JWT + Telegram identity verification + webUI + admin panel) ### Auth (JWT + Telegram identity verification + webUI + admin panel)
@ -1185,14 +1184,14 @@ Same Ollama env as above; no DB/MQ/S3 env needed.
## 12. Docker — images & compose ## 12. Docker — images & compose
### Per-service Dockerfile pattern (uv, multi-stage, py3.14) ### Per-service Dockerfile pattern (uv, multi-stage, py3.13)
Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape
(shown for api): (shown for api):
```dockerfile ```dockerfile
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/app/.venv UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app WORKDIR /app
@ -1202,7 +1201,7 @@ COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api uv sync --frozen --no-default-groups --group api
FROM python:3.14-slim-trixie AS runtime FROM python:3.13-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
@ -1219,14 +1218,14 @@ Per-service differences (`uv` uses PEP 735 dependency-groups — see `pyproject.
| Dockerfile | Extra installed | Runtime apt | CMD | Expose | | Dockerfile | Extra installed | Runtime apt | CMD | Expose |
|---|---|---|---|---| |---|---|---|---|---|
| `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 | | `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 |
| `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1t64 | `python -m contract_check.worker_extract` | 9101 | | `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1 | `python -m contract_check.worker_extract` | 9101 |
| `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 | | `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 |
| `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 | | `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 |
| `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — | | `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — |
The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The
analyze image has httpx but no tesseract/pymupdf. The extract image is the analyze image has httpx but no tesseract/pymupdf. The extract image is the
heaviest (tesseract + language packs + libmagic1t64). This is the "fine-tuned deps heaviest (tesseract + language packs + libmagic1). This is the "fine-tuned deps
per service" payoff. per service" payoff.
### pyproject.toml dependency-groups (actual — PEP 735) ### pyproject.toml dependency-groups (actual — PEP 735)
@ -1240,7 +1239,7 @@ that need them. Sketch (see `pyproject.toml` for the authoritative list):
```toml ```toml
[project] [project]
name = "contract-check" name = "contract-check"
requires-python = ">=3.14" requires-python = ">=3.13"
dependencies = [ dependencies = [
"pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1", "pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1",
"python-dotenv>=1.0", "httpx[http2]>=0.27", "python-dotenv>=1.0", "httpx[http2]>=0.27",
@ -1250,21 +1249,25 @@ dependencies = [
db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"] db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"]
mq = ["aio-pika>=9.4"] mq = ["aio-pika>=9.4"]
s3 = ["minio>=7.2"] s3 = ["minio>=7.2"]
obs = ["prometheus-client>=0.20", "sentry-sdk>=2"] obs = ["prometheus-client>=0.20", "sentry-sdk>=2",
"opentelemetry-sdk>=1.24", "opentelemetry-exporter-otlp>=1.24"]
api = [{ include-group = "db" }, { include-group = "mq" }, api = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }, { include-group = "s3" }, { include-group = "obs" },
"fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", "fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9",
"redis>=5.0"] "redis>=5.0",
"opentelemetry-instrumentation-fastapi>=0.45b0",
"opentelemetry-instrumentation-asgi>=0.45b0"]
extract = [{ include-group = "db" }, { include-group = "mq" }, extract = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }, { include-group = "s3" }, { include-group = "obs" },
"pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10", "pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10",
"mammoth>=1.8", "striprtf>=0.0.26", "chardet>=5.2", "mammoth>=1.8", "striprtf>=0.0.26", "chardet>=5.2",
"python-magic>=0.4.27"] "python-magic>=0.4.27"]
analyze = [{ include-group = "db" }, { include-group = "mq" }, analyze = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }] { include-group = "s3" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0"]
prescreen = [{ include-group = "db" }, { include-group = "mq" }, prescreen = [{ include-group = "db" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "obs" }] { include-group = "s3" }, { include-group = "obs" }]
bot = ["aiogram>=3.4"] bot = ["aiogram>=3.4"]
dev = [{ include-group = "api" }, { include-group = "extract" }, dev = [{ include-group = "api" }, { include-group = "extract" },
{ include-group = "prescreen" }, { include-group = "analyze" }, { include-group = "prescreen" }, { include-group = "analyze" },
@ -1280,8 +1283,8 @@ One file, profiles. Default (`docker compose up`) = infra only.
- `services` profile = api + workers (no bot). - `services` profile = api + workers (no bot).
- `bot` profile = Telegram bot adapter; can run on the same host or a separate - `bot` profile = Telegram bot adapter; can run on the same host or a separate
server (`docs/DEPLOY.md` §14). server (`docs/DEPLOY.md` §14).
- `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + Vector) - `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + OTel
are mutually exclusive observability stacks. Collector) are mutually exclusive observability stacks.
- `edge` = Nginx + certbot. - `edge` = Nginx + certbot.
``` ```
@ -1313,9 +1316,9 @@ services:
prometheus: prom/prometheus; scrapes service /metrics endpoints prometheus: prom/prometheus; scrapes service /metrics endpoints
# ── OBSERVABILITY — OPENOBSERVE STACK (profile: observer) ── # ── OBSERVABILITY — OPENOBSERVE STACK (profile: observer) ──
openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + JSON/Prometheus-write backend on :5080 openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + OTLP backend on :5080, gRPC on :5081
vector: timberio/vector:0.43.0-alpine; reads Docker logs + scrapes /metrics, otel-collector: otel/opentelemetry-collector-contrib:latest; OTLP receiver 4317/4318,
forwards logs/metrics to OpenObserve scrapes /metrics from services, forwards logs/metrics/traces to OpenObserve
# ── EDGE (profile: edge) ── # ── EDGE (profile: edge) ──
nginx: nginx:alpine; reverse proxy → api/openobserve/grafana; TLS via certbot nginx: nginx:alpine; reverse proxy → api/openobserve/grafana; TLS via certbot
@ -1341,8 +1344,7 @@ enforcing the adapter boundary even in dependency ordering.
- **workers** read `headers["x-correlation-id"]` on consume and set the same - **workers** read `headers["x-correlation-id"]` on consume and set the same
contextvar before handling. So a single upload's logs trace contextvar before handling. So a single upload's logs trace
api → rabbit → worker-extract → worker-analyze → DB under one ID. api → rabbit → worker-extract → worker-analyze → DB under one ID.
- Distributed tracing is intentionally out of scope; `correlation_id` in - OTel baggage/span context propagates the same ID for distributed traces.
structlog already provides request-level correlation across services.
- The same `correlation_id` is parsed by the Loki datasource as a derived field, - The same `correlation_id` is parsed by the Loki datasource as a derived field,
so you can click through from any log line to every other log line with the so you can click through from any log line to every other log line with the
same id. same id.
@ -1389,36 +1391,35 @@ enforcing the adapter boundary even in dependency ordering.
- `prometheus` in the `obs` profile scrapes `:9100`/`:9101`/`:9102`/`:9104`/`:9105`/`:9103` - `prometheus` in the `obs` profile scrapes `:9100`/`:9101`/`:9102`/`:9104`/`:9105`/`:9103`
metrics exposed by the services. metrics exposed by the services.
- Tracing may be added later as a separate feature evaluated against the - Tempo will receive OTLP traces via the OpenTelemetry collector when added.
passive-collection principle.
- Starter dashboards for queue depth, job latency, LLM tokens/min, refund rate, - Starter dashboards for queue depth, job latency, LLM tokens/min, refund rate,
and 429/fallback rate will ship with the metrics datasources. and 429/fallback rate will ship with the metrics/traces datasources.
### OpenObserve + Vector (`observer` profile) ### OpenObserve + OTel Collector (`observer` profile)
- `vector` runs in the `observer` profile. It reads container logs from the - `otel-collector` runs in the `observer` profile and listens for OTLP on
local Docker socket and scrapes Prometheus `/metrics` endpoints inside the `4317` (gRPC) and `4318` (HTTP).
compose network (`api:8000/metrics`, workers on `:9101`/`:9102`/`:9104`/`:9105`/`:9103`). - Services export OTLP traces, metrics, and logs to `http://otel-collector:4318`
- Vector enriches logs with `service` (container name) and preserves (`OTEL_EXPORTER_OTLP_ENDPOINT`).
`correlation_id` from JSON log lines. - The collector also scrapes Prometheus `/metrics` from `api:9100` and each
- Logs are sent to worker (`:9101`/`:9102`/`:9104`/`:9105`/`:9103`).
`http://openobserve:5080/openobserve/api/default/contract_check/_json` and - `otlphttp/openobserve` exporter forwards logs and traces to
metrics to `http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write`, `http://openobserve:5080/openobserve/api/default` using `OPENOBSERVE_AUTH_TOKEN`
both authenticated with the OpenObserve root credentials (the `/openobserve` prefix matches `ZO_BASE_URI`).
(`OPENOBSERVE_ROOT_USER_EMAIL` / `OPENOBSERVE_ROOT_USER_PASSWORD`). - `prometheusremotewrite/openobserve` exporter forwards metrics to
`http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write`
because OpenObserve ingests metrics through the Prometheus remote-write path.
- OpenObserve UI is exposed under `/openobserve/` via nginx. - OpenObserve UI is exposed under `/openobserve/` via nginx.
- Worker metrics ports are no longer published on the Docker host; they are only
reachable inside the compose network for scraping.
- The API metrics port is bound to `127.0.0.1` by default to avoid accidental
public exposure.
### Telemetry module (`core/telemetry.py`) ### OpenTelemetry (`core/telemetry.py`)
- `setup_telemetry()` / `shutdown_telemetry()` are safe no-op stubs. The
application does not import or initialize the OpenTelemetry SDK.
- `configure_logging` routes structlog through a root `StreamHandler`, so logs
reach stdout/stderr and are collected passively by Vector or Promtail.
- OTLP exporter sends traces, metrics, and logs to the endpoint configured in
`OTEL_EXPORTER_OTLP_ENDPOINT` (collector in `observer` profile, Tempo in `obs`).
- Auto-instrument FastAPI (api), httpx (all outbound, incl. Ollama calls).
- `configure_logging` routes structlog through stdlib logging so the OTEL log
handler captures application logs with their structured fields.
- Spans carry the same `correlation_id` as logs. Trace from HTTP request →
RabbitMQ publish → consume → LLM call is one trace tree.
--- ---
@ -1689,7 +1690,7 @@ No business logic, no direct state. The hexagonal rule.
- **One image = one entrypoint**; `MODE` dispatcher is dead. - **One image = one entrypoint**; `MODE` dispatcher is dead.
- **Publisher confirms** on; a published-but-unconfirmed message fails the - **Publisher confirms** on; a published-but-unconfirmed message fails the
HTTP request. HTTP request.
- **Correlation ID** flows HTTP → message header → logs. - **Correlation ID** flows HTTP → message header → logs → traces.
--- ---
@ -1756,7 +1757,7 @@ Each step is a verifiable unit. Do not start step N+1 until N is green
- ЮKassa + `invoices` logic + recurring (separate effort). - ЮKassa + `invoices` logic + recurring (separate effort).
- React SPA + Telegram Login + SSE/webhook delivery. - React SPA + Telegram Login + SSE/webhook delivery.
- Grafana dashboards polished. - Grafana dashboards polished; Tempo/Jaeger trace UI.
--- ---

View file

@ -1,363 +0,0 @@
# ARCHITECTURE (AS BUILT) — «Контракт-чек»
> This document describes the system **as it exists in the code today**, verified
> against `src/`, `migrations/`, `tests/`, and compose files. The design-time
> rationale, decision history and future steps remain in
> [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) (design doc); this file is the
> as-built map. Domain language is defined in the root [`CONTEXT.md`](../CONTEXT.md).
---
## 1. What the system is
LLM-powered screening of contract (deal) documents under Russian / Belarusian
civil law. A user (Telegram bot, web JWT auth, or B2B API key) uploads a
document; an event-driven pipeline extracts text, prescreens it, optionally
runs deep LLM analysis, and produces a markdown report with findings, quotes
and clause references. Document consumption is metered: subscription Quota
first, then prepaid Credits, with ЮKassa payments (topups, subscriptions,
renewals, refunds with clawback).
**Stack:** Python 3.14, uv + hatchling, FastAPI, SQLAlchemy 2 (async) +
Alembic, aio-pika (RabbitMQ), MinIO (S3), Redis, aiogram 3, httpx,
Prometheus + structlog + Sentry, OpenObserve/Vector (or Grafana/Loki) for
observability, Nginx + certbot at the edge. Docker Compose deployment
(one Dockerfile per service in `srv/`), k8s-ready but not k8s-running.
---
## 2. Hexagonal shape and import rules
```
src/contract_check/
├── core/ # domain — owns ALL state and side effects
│ ├── db/ # models, session, repositories/
│ ├── mq/ # topology, publisher, consumer, messages, management
│ ├── s3/ # Storage port + MinIO adapter
│ ├── llm/ # LLMProvider port + ollama_cloud / yandex_gpt adapters
│ ├── billing/ # PaymentProvider port + yookassa, quota, fulfillment, refunds
│ ├── extraction/ # DocumentExtractor port + format adapters
│ ├── analysis/ # chunker, checklist, analyzer, report_schema
│ ├── notifications/ # SMTP transport + notify publisher
│ ├── security/ # argon2 passwords
│ └── config.py logging.py metrics.py telemetry.py sentry.py errors.py
│ credits.py tokens.py api_keys.py rate_limit.py auth*.py passkeys.py
│ redis_client.py review/
├── api/ # FastAPI image (routes/, admin/, billing/ pay pages, schemas/)
├── worker_extract/ # CPU image: pymupdf + mammoth + striprtf + chardet + tesseract
├── worker_prescreen/# hybrid metadata extraction + routing
├── worker_analyze/ # I/O image: LLM provider
├── worker_notify/ # email (aiosmtplib)
├── worker_billing/ # timer scheduler (no MQ)
└── bot/ # aiogram adapter — HTTP-only
```
**Boundary rules (statically enforced for the bot by
`tests/unit/test_bot_boundary.py`):**
- `core/*` may import anything.
- `api/*`, `worker_*/*` import only `core/*` (+ their own modules).
- `bot/*` imports only `httpx`/`aiohttp`/aiogram and its own modules — never
`core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`. It talks to the
api over HTTP exclusively, and may run on a separate host
(`docker-compose.bot.yml`).
**Control plane:** `api` is the only writer for user-initiated mutations
(upload → reserve Document Slot → MinIO → publish). Workers write their own
stage rows / reports; credits move only through the idempotent compensation
helpers (`core/billing/quota.compensate_document_slot`, `core/credits.refund_credit`).
---
## 3. Services and the pipeline
```
Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ
owns: PG, MinIO, Redis, │
quota/credits, tokens ▼
extract.q ──► worker-extract
(CPU: pymupdf/mammoth/tesseract)
│ publish
prescreen.q ──► worker-prescreen
(hybrid heuristic + optional LLM)
│ deep_analysis │ manual_review (terminal)
▼ ▼
analyze.q ──► worker-analyze ──► Postgres (Report, done)
notify.x ──► notify.q ──► worker-notify (SMTP: reset, magic link, …)
worker-billing (60 s Postgres-advisory-lock tick; no MQ)
```
### api (`api/`, `srv/api/`)
- `create_app` (`api/app.py:115`): logging/Sentry, MinIO `ensure_bucket`,
MQ publisher (publisher confirms), Redis rate-limiter with in-memory
fallback, admin seed. Middleware: CORS, correlation-id propagation,
`http_request_duration`, access logs, redacted debug payloads, generic 500.
- Routes under `/api/v1`: `auth/` (Telegram bot/web/miniapp, email+password
JWT pair, passkeys, magic links, support), `documents` (upload → 202),
`reports` (polling + SSE `/events`), `me` (profile, overview, documents,
telegram bind, password), `billing` (plans, checkout, invoices,
subscription, autorenew, refund), `b2b` (API-key analyze/status/usage/
profile/keys CRUD), `webhooks/yookassa`. Plus `/healthz`, `/readyz`,
`/metrics` (bearer-gated), server-rendered `/pay/{invoice_id}` (short-lived
JWT pay token), and `/admin/*` (Jinja2 + HTMX panel: users, credits,
invoices/refunds, hold clearing, gift subscriptions, manual-review queue,
MQ queues/DLQ requeue).
- **Upload flow** (`api/services.py:56` `upload_and_enqueue`): validate
suffix/format → reject on `billing_hold` (402) → stream body with size cap
(413) → MinIO put (DB failure triggers best-effort S3 cleanup) → insert
`documents` (queued) + `jobs` (extract/pending) → reserve Document Slot
(Quota-then-Credit when `PLANS_ENABLED`, else legacy `reserve_credit`; 402
on `NoCredits`) → publish `DocumentUploaded` (publisher confirms; on MQ
failure: release slot / refund credit, status `publish_failed`) →
`202 {document_id, correlation_id, credits_left}`.
### worker-extract (`worker_extract/`, metrics :9101, prefetch 1)
Consumes `extract.q` / `DocumentUploaded`. Idempotency check against terminal
statuses → status `extracting` → download blob → `extract_document`
(`core/extraction/factory.py`: python-magic sniff → uploader MIME → suffix;
adapters: pdf/pymupdf, docx/mammoth, rtf/striprtf, txt+csv/chardet,
images/tesseract OCR; unsupported formats are terminal + refunded) → upload
extracted markdown to MinIO → publish `PrescreenRequested` (or
`DocumentExtracted` straight to analyze.q when `PRESCREEN_ENABLED=false`).
Failure classes: `extraction_failed` (non-refundable under `infra_only`),
`ocr_failed`, `infra`. Terminal failure → DLQ + slot compensation.
### worker-prescreen (`worker_prescreen/`, metrics :9104, prefetch 1)
Consumes `prescreen.q` / `PrescreenRequested`. Handler
(`worker_prescreen/handler.py:116`): transition `prescreening` → download
text → **hybrid extractor** (`extractor_hybrid.py`): Stage 1 deterministic
heuristic (`heuristic-v2`, regex-free, keyword/positional); Stage 2 LLM
fallback only if enabled AND confidence < `PRESCREEN_LLM_FALLBACK_THRESHOLD`
(LLM failure never fatal; LLM fills heuristic `None`s, booleans OR-merged,
confidence rescored) → **router** (`router.py:30`): missing
type/parties/low-confidence → `manual_review` (terminal status, admin queue);
amount ≥ `PRESCREEN_HIGH_VALUE_THRESHOLD` or penalty/arbitration clause →
`deep_analysis` (publish `AnalyzeRequested` with `prescreen_meta`);
`auto_approve` disabled by default (remapped to manual_review; when enabled
writes a lightweight Report, `status=done`). Persists `prescreen_results`.
### worker-analyze (`worker_analyze/`, metrics :9102, prefetch 3)
Consumes `analyze.q` / `AnalyzeRequested`. Status `analyzing/llm` → download
text → `provider.analyze(text, checklist)` (chunking, fan-out under
`asyncio.Semaphore(LLM_MAX_CONCURRENCY)`, json-schema + repair loop, 429 →
fallback model — all inside the LLM adapter) → `render_markdown` (findings,
quotes, clause refs, «не заменяет юриста» disclaimer) → `reports` upsert,
`status=done`. Failure classes: `llm_quota`, `llm_timeout`,
`llm_invalid_output`, `infra`; DLQ → slot compensation.
### worker-notify (`worker_notify/`, metrics :9103, prefetch 5)
Consumes `notify.q` / `NotificationMessage` (kinds: password_reset, welcome,
email_verification, magic_link). SMTP via aiosmtplib; dev logger when
`SMTP_HOST` empty.
### worker-billing (`worker_billing/`, metrics :9105, no MQ)
60-second tick guarded by a **Postgres advisory lock** (single replica):
renewal invoices for auto-renew subscriptions expiring < 3 days; period roll /
`past_due` (grace) / `expired` transitions; reconciliation of pending
invoices older than 15 minutes via the same `apply_payment_status` used by
the webhook. No-op safe when ЮKassa disabled.
### bot (`bot/`, `srv/bot/`)
aiogram 3, polling or webhook mode (aiohttp, secret-token auth). Commands:
`/start /help /profile /balance /plans /reports /status`; checkout callbacks
(`plan:*`, `topup:*`); document upload → API → poll report with stage labels;
per-user rate limit (Redis, memory fallback); service-token login cached per
`telegram_id`; `ensure_disclaimer` guarantees the legal disclaimer.
---
## 4. RabbitMQ topology (declared idempotently in `core/mq/topology.py`)
| Object | Type / args | Purpose |
|---|---|---|
| `contracts.x` | direct | main exchange; RK `extract` / `prescreen` / `analyze` |
| `extract.q` / `prescreen.q` / `analyze.q` | **quorum**, DLX → `contracts.retry.x` | main work queues |
| `contracts.retry.x` + `*.retry.q` | direct + classic (per-message TTL, `lazy` policy via mgmt API) | delay slots; TTL expiry dead-letters the message back to `contracts.x` |
| `extract.dlq` / `prescreen.dlq` / `analyze.dlq` | quorum | poison; manual requeue via admin panel / `core/mq/management.py` |
| `notify.x` / `notify.q` / `notify.retry.q` / `notify.dlq` | mirror of the above | notification pipeline |
**Retry mechanics** (`core/mq/consumer.py`): generic `Consumer[MsgT]` with
`handle`/`classify`/`on_failure`/`on_dlq` hooks. On failure the consumer
re-publishes to the retry exchange with `expiration = MQ_RETRY_BASE_MS ·
2^(attempt1)` (default 2 s base → 2/4/8/16/32 s) and acks the original.
`x-attempt >= MQ_MAX_ATTEMPTS` (5) → DLQ with `x-failure-class` headers,
`jobs.dlq=true`, `documents.status=failed`, slot compensation. Pydantic
validation failure → straight to DLQ (`infra` poison). `core.errors.TerminalError`
bypasses retries. If the failure hooks themselves raise, the message is
retried (hook-failure fallback). Publisher uses **publisher confirms**
unconfirmed publish fails the request, so a paid slot never silently vanishes.
Message schemas (`core/mq/messages.py`, pydantic v2, base `PipelineMessage`
with `next_attempt()`): `DocumentUploaded`, `DocumentExtracted`,
`PrescreenRequested`, `AnalyzeRequested` (carries `prescreen_meta`),
`PrescreenCompleted`, `NotificationMessage`. Headers: `x-correlation-id`,
`x-attempt`, `x-origin`; `delivery_mode=2`.
**Idempotency:** every handler re-reads `documents.status` first; terminal or
in-flight → ack and exit. No double-LLM, no double-refund.
---
## 5. Data model (Postgres, 16 tables, `core/db/models.py`; migrations 00010011)
| Table | Role |
|---|---|
| `users` | identity (telegram_id and/or email), argon2 `password_hash`, reset/magic-link token hashes, `role` (user/admin), `credits_left` (CHECK ≥ 0), `billing_hold` |
| `passkey_credentials` | WebAuthn credentials (credential_id, public_key, sign_count) |
| `documents` | one upload: s3 keys, status `queued→extracting→prescreening→ocr→analyzing→done\|failed\|manual_review`, `refunded` |
| `reports` | 1:1 with document: `content_json` JSONB + `markdown`, model, tokens, latency, prescreen link |
| `prescreen_results` | extracted metadata (type, parties, amount, dates, clause flags), `confidence_score`, `routing_decision`, `extractor_version` |
| `jobs` | per-stage correlation (UNIQUE document+queue), attempts, `last_failure_class`, `dlq` |
| `service_tokens` | per-adapter bearer auth (bot/web/cli), revocable |
| `api_keys` / `api_key_requests` | B2B keys (SHA-256 hash, per-key rps + monthly quota) and per-call usage ledger |
| `invoices` | money in **integer kopecks**; kinds `topup`/`subscription`/`renewal`; statuses draft→pending→succeeded/cancelled/refunded |
| `plans` / `subscriptions` / `quota_usage` | seeded plan catalog; one active-or-past_due subscription per user (partial unique); quota ledger with UNIQUE(document_id) idempotency |
| `credit_events` | append-only credit ledger (`delta ≠ 0`, `balance_after ≥ 0` self-verifying) |
| `user_profiles` | 1:1 passive settings (language, TZ, notif/dashboard prefs) |
**Data access:** all SQL lives in `core/db/repositories/` (14 repositories);
a repository receives an `AsyncSession` and never commits — callers own
transactions. Raw `text()` only inside repositories/migrations. Status/enum
columns are `TEXT + CHECK` (not PG enums) so migrations stay additive. Alembic
is async; CI exercises `upgrade head``downgrade base`.
---
## 6. Credits, Quota, refunds (billing invariants, `core/billing/`)
- **Reserve-on-enqueue:** the API synchronously reserves a Document Slot
before publishing (`core/billing/quota.py:22`
`reserve_document_slot`): `SELECT … FOR UPDATE` on the active subscription →
quota rows this period < `plan.monthly_quota` → insert `quota_usage`
(`ON CONFLICT DO NOTHING`) → source `quota`; else atomic credit reserve →
`credits`; else 402. `PLANS_ENABLED=false` → legacy credits-only path.
- **Compensation (exactly-once):** on terminal processing failure,
`compensate_document_slot` (`quota.py:100`) releases the quota row or
refund the credit (guarded by `documents.refunded`), honouring
`REFUND_POLICY=all|infra_only` with the failure-class taxonomy
(`extraction_failed` non-refundable under `infra_only`).
- **Payments:** `PaymentProvider` port (`port.py`) + `YookassaProvider`
(httpx, Basic auth, Idempotence-Key = invoice UUID, kopecks↔RUB only at
the HTTP boundary). `YOOKASSA_ENABLED=false``PaymentsDisabled`
billing mutations 503, catalog readable.
- **Webhook trust** (`api/routes/webhooks.py`): Basic auth constant-time
check, then the payment is **re-fetched via REST** — the push payload's
amount is never trusted; fulfillment is idempotent
(`core/billing/fulfillment.apply_payment_status`), also used by the
reconciliation sweep.
- **Refunds (D10)** (`core/billing/refunds.py`): within `REFUND_WINDOW_DAYS`
(14) and usage ≤ 20 % of purchased → full; else proportional
`max(0, amount used × PRICE_PER_DOC)`; otherwise 409. Execution:
provider refund → invoice `refunded`**clawback** (unspent credits /
subscription + period quota rows) → `users.billing_hold = TRUE` when the
balance would go negative; uploads 402 until an admin clears the hold.
---
## 7. LLM provider port (`core/llm/`)
`LLMProvider` Protocol (`port.py:31`): `analyze(text, *, checklist)` and
`extract_prescreen(text)`. Registered providers via `LLM_PROVIDER`:
`ollama_cloud` (default; `format: json-schema` chat, model
`qwen2.5:14b`, fallback `qwen2.5:7b`) and `yandex_gpt`
(`yandexgpt-lite`, `Api-Key`, JSON_OBJECT). Shared behaviour in each adapter:
chunk fan-out under a semaphore, repair loop (one REPAIR re-send on invalid
JSON, then `LLMError`), 429 → fallback model → `LLMQuotaError`, exponential
HTTP backoff for timeouts/5xx, terminal `LLMConfigError` for connect/404.
Checklist: 10 frozen items (`core/analysis/checklist.py`); `Finding` schema
(`report_schema.py`) doubles as the Ollama json-schema and tolerates alias
field names.
---
## 8. AuthN/AuthZ surface
| Caller | Mechanism |
|---|---|
| bot adapter | `Authorization: Bearer <service_token>` (only `/api/v1/auth/telegram/bot`) → user JWT exchange |
| Telegram web / Mini App | Login Widget hash / `initData` verification → JWT |
| web user | email+password → JWT pair (access 24 h + refresh 30 d, Redis-backed revocation); passkeys (WebAuthn, Redis challenges) and magic links → single access JWT |
| B2B | `X-API-Key` (SHA-256, token-bucket rate limit, monthly quota) |
| admin panel | HttpOnly cookie + `users.role = admin` |
| ЮKassa webhook | Basic (shopId:secret) |
| health/metrics | none / bearer |
Redis is used only for rate limiting, refresh-token store, passkey
challenges and bot limiter — never as a job queue.
---
## 9. Storage (MinIO, `core/s3/`)
Single bucket `contract-check-docs`; keys `users/{uid}/docs/{did}.{ext}`
(original) and `users/{uid}/docs/{did}.txt` (extracted markdown), built only
via key builders. Uploads proxy through the API (multipart, 25 MiB cap,
streamed with limit). `minio-init` one-shot creates the bucket and the ILM
expiry (`DOC_RETENTION_DAYS` 7 / `TEXT_RETENTION_DAYS` 30) — 152-ФЗ lever:
raw text leaves on schedule; reports live in Postgres and survive the purge.
---
## 10. Observability & deploy
- **Logs/metrics/traces:** structlog JSON + correlation-id contextvar
(propagated through HTTP headers and MQ headers), Prometheus per-service
(`/metrics` on api :9100; workers :9101:9105 scraped in-network by
Vector), Sentry (DSN-gated). Passive collection: `observer` profile =
OpenObserve + Vector; `obs` profile = Prometheus + Loki + Promtail +
Grafana. OTLP push was removed.
- **Compose** (`docker-compose.yml`): default = infra only (postgres:18 with
`wal_level=replica` + WAL archive, redis:8, rabbitmq:4, minio + minio-init);
profiles `services` (api + 5 workers), `bot` (standalone-able),
`edge` (nginx + certbot + exporter), `observer`/`obs`. Host ports offset:
PG 15432, Redis 17379, Rabbit 5672/15672, MinIO 9000/9001, api 8000,
OpenObserve 5080, edge 80/443.
- **Images:** one Dockerfile per service in `srv/` (two-stage uv build,
`uv sync --no-default-groups --group <grp>` → python:3.14-slim); dependency
groups (PEP 735) per service keep images minimal; bot is leanest.
- **CI** (`.github/workflows/ci.yml`): ruff check+format, `ty` typecheck,
unit tests with `--cov-fail-under=50`, integration tests against an
isolated compose test stack (`docker-compose.test.yml`, offset ports).
---
## 11. Testing
- **Unit** (`tests/unit/`, ~40 files / 326 tests): no infra; respx for LLM
HTTP; includes `test_bot_boundary.py` (AST check of the hexagonal bot rule).
- **Integration** (`tests/integration/`, 21 files / 134 tests, `-m
integration`): full docker-compose test stack, alembic upgrade, seeded
service token, ASGI httpx + LifespanManager; covers upload pipeline, each
worker, credits/quota DB, auth flows, passkeys/magic links, B2B, admin
panel, billing checkout/webhook/pay page, subscriptions, refunds.
- `tests/conftest.py` deduplicates `src.contract_check.*` vs
`contract_check.*` module loading (avoids duplicate Prometheus timeseries).
---
## 12. Cross-cutting conventions
1. Money is integer kopecks everywhere; decimals exist only at the ЮKassa
HTTP boundary.
2. Enum-ish columns are `TEXT + CHECK`; migrations additive and hand-written.
3. Repositories never commit; callers own transactions.
4. Every queue handler is idempotent via `documents.status` re-read;
refunds/slot releases are exactly-once via guards.
5. Publisher confirms on every publish that follows a paid reservation.
6. External providers (LLM, payments, storage) sit behind ports in `core/`;
adapters are swappable and respx-testable.
7. Feature degrade switches: `PLANS_ENABLED`, `YOOKASSA_ENABLED`,
`PRESCREEN_ENABLED`, `PRESCREEN_LLM_FALLBACK_ENABLED`,
`SMTP_HOST` (empty = dev logger), `REFUND_POLICY`.
8. Domain language (Plan/Subscription/Quota/Credits/Topup/Invoice/Renewal/
Saved Payment Method/Dunning/Signup Bonus/ЕРИП/Billing Hold/Clawback/
Document Slot) is normative — see root `CONTEXT.md`; ADRs live in
`docs/adr/`.

View file

@ -144,11 +144,9 @@ PRICE_PER_DOC_KOPECKS=19900 # 199 ₽ за документ без подпис
# --- Observability (опционально) --- # --- Observability (опционально) ---
SENTRY_DSN=https://...@sentry.io/... SENTRY_DSN=https://...@sentry.io/...
# Vector (профиль `observer`) авторизуется в OpenObserve с этими кредами # OTLP/HTTP endpoint. With profile `observer` use the local collector.
# (совпадают с root-пользователем OpenObserve). OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com OPENOBSERVE_AUTH_TOKEN=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
# OTEL_EXPORTER_OTLP_ENDPOINT больше не используется: приложение не шлёт OTLP.
``` ```
> Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. Включение > Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. Включение
@ -203,7 +201,7 @@ make shell-db # UPDATE plans SET price_kopecks = 59000 WHERE code = 'lite';
| `services` | `api`, `worker-extract`, `worker-prescreen`, `worker-analyze`, `worker-billing`, `worker-notify` | | `services` | `api`, `worker-extract`, `worker-prescreen`, `worker-analyze`, `worker-billing`, `worker-notify` |
| `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере | | `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере |
| `obs` | `loki`, `promtail`, `grafana`, `prometheus` (logs + metrics; traces — позже) | | `obs` | `loki`, `promtail`, `grafana`, `prometheus` (logs + metrics; traces — позже) |
| `observer` | `openobserve`, `vector` (logs + metrics; приложение не шлёт OTLP) | | `observer` | `openobserve`, `otel-collector` (logs + metrics + traces через collector) |
| `edge` | `nginx`, `certbot` | | `edge` | `nginx`, `certbot` |
```bash ```bash
@ -222,7 +220,7 @@ docker compose --profile services --profile bot up -d --build
# + observability Grafana/Loki/Prometheus (логи + метрики): # + observability Grafana/Loki/Prometheus (логи + метрики):
docker compose --profile services --profile bot --profile obs up -d --build docker compose --profile services --profile bot --profile obs up -d --build
# + observability OpenObserve (логи + метрики через Vector; приложение не шлёт OTLP): # + observability OpenObserve (логи + метрики + трейсы через otel-collector):
docker compose --profile services --profile bot --profile observer up -d --build docker compose --profile services --profile bot --profile observer up -d --build
# + edge (nginx reverse proxy + TLS; deploy/nginx готов): # + edge (nginx reverse proxy + TLS; deploy/nginx готов):
@ -307,9 +305,7 @@ docker compose --profile bot logs -f bot
```bash ```bash
curl http://localhost:8000/healthz # liveness — 200 curl http://localhost:8000/healthz # liveness — 200
curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO) curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO)
# /metrics доступен на loopback по умолчанию; снаружи хоста — только через curl http://localhost:8000/metrics # Prometheus exposition
# edge/nginx или по туннелю. Для проверки внутри контейнера:
docker compose exec api curl -s http://localhost:8000/metrics
``` ```
### 6.2 RabbitMQ Management UI ### 6.2 RabbitMQ Management UI
@ -634,8 +630,7 @@ curl https://contract-check.example.com/healthz
По умолчанию `nginx.conf` проксирует: По умолчанию `nginx.conf` проксирует:
- `/api/v1/*`, `/admin/*` - `/api/v1/*`, `/admin/*`
- `/healthz`, `/readyz` - `/healthz`, `/readyz`
- `/metrics` → проксируется наружу; настройте `METRICS_BEARER_TOKEN` и ограничьте доступ - `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг)
файрволом/edge, чтобы метрики не утекали наружу. Worker-метрики доступны только внутри сети compose.
- `/webhook/*``/api/v1/webhooks/` - `/webhook/*``/api/v1/webhooks/`
- `${BOT_WEBHOOK_PATH}``bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4) - `${BOT_WEBHOOK_PATH}``bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
- `/grafana/*``grafana:3000` (когда поднят профиль `obs`) - `/grafana/*``grafana:3000` (когда поднят профиль `obs`)

File diff suppressed because one or more lines are too long

View file

@ -1,60 +0,0 @@
{
"schemaVersion": 1,
"ok": false,
"command": "visual-check",
"evidenceKind": "automated-browser",
"status": "skipped",
"visualReview": "pending",
"artifact": {
"path": "/home/san/PythonProjects/DealDocumentScreening/docs/diagrams/contract-check-architecture.html",
"sha256": "14b219ba1da1f4525e59338b04dadf208ade2b6bdc30eb779875c9842364f0c7",
"bytes": 846404
},
"state": {
"detail": "read",
"motion": "still"
},
"chrome": {
"status": "unavailable",
"executable": null
},
"diagnostics": [
{
"code": "viewer/chrome-unavailable",
"severity": "warning",
"message": "Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.",
"subject": {
"artifact": "/home/san/PythonProjects/DealDocumentScreening/docs/diagrams/contract-check-architecture.html"
},
"evidence": {
"executable": null
},
"supportedFixes": [
"set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun visual-check"
]
}
],
"containment": {
"status": "skipped",
"viewports": []
},
"readability": {
"status": "skipped",
"minimumProjectedNodeTextPx": 6,
"viewports": []
},
"viewerChrome": {
"status": "skipped",
"viewports": []
},
"captures": {
"status": "skipped",
"screenshots": [],
"contactSheet": null
},
"sidecars": {
"receipt": "contract-check-architecture.visual-check.json",
"contactSheet": "contract-check-architecture.visual-check.html"
},
"error": "Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path."
}

View file

@ -7,7 +7,7 @@ name = "contract-check"
version = "0.1.0" version = "0.1.0"
description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app." description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app."
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.13"
license = { text = "Proprietary" } license = { text = "Proprietary" }
authors = [{ name = "Контракт-чек" }] authors = [{ name = "Контракт-чек" }]
keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"] keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"]
@ -32,7 +32,7 @@ packages = ["src/contract_check"]
# #
# api → core + db/mq/s3/obs + fastapi/uvicorn # api → core + db/mq/s3/obs + fastapi/uvicorn
# worker-extract → core + db/mq/s3/obs + pymupdf/tesseract # worker-extract → core + db/mq/s3/obs + pymupdf/tesseract
# worker-analyze → core + db/mq/s3/obs # worker-analyze → core + db/mq/s3/obs + otel-httpx
# bot → core + aiogram (leanest image: no db/mq/s3/llm/tesseract) # bot → core + aiogram (leanest image: no db/mq/s3/llm/tesseract)
# dev → everything needed to lint/typecheck/test locally # dev → everything needed to lint/typecheck/test locally
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -51,6 +51,8 @@ s3 = [
obs = [ obs = [
"prometheus-client>=0.20", "prometheus-client>=0.20",
"sentry-sdk>=2", "sentry-sdk>=2",
"opentelemetry-sdk>=1.24",
"opentelemetry-exporter-otlp>=1.24",
] ]
api = [ api = [
{ include-group = "db" }, { include-group = "db" },
@ -66,6 +68,9 @@ api = [
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"email-validator>=2.1", "email-validator>=2.1",
"webauthn>=2.5", "webauthn>=2.5",
"opentelemetry-instrumentation-fastapi>=0.45b0",
"opentelemetry-instrumentation-asgi>=0.45b0",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
extract = [ extract = [
{ include-group = "db" }, { include-group = "db" },
@ -79,18 +84,21 @@ extract = [
"striprtf>=0.0.26", "striprtf>=0.0.26",
"chardet>=5.2", "chardet>=5.2",
"python-magic>=0.4.27", "python-magic>=0.4.27",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
analyze = [ analyze = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "s3" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
prescreen = [ prescreen = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "s3" }, { include-group = "s3" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
bot = [ bot = [
"aiogram>=3.4", "aiogram>=3.4",
@ -104,10 +112,12 @@ notify = [
{ include-group = "mq" }, { include-group = "mq" },
{ include-group = "obs" }, { include-group = "obs" },
"aiosmtplib>=3.0", "aiosmtplib>=3.0",
"opentelemetry-instrumentation-httpx>=0.45b0",
] ]
billing = [ billing = [
{ include-group = "db" }, { include-group = "db" },
{ include-group = "obs" }, { include-group = "obs" },
"opentelemetry-instrumentation-httpx>=0.45b0",
"pyjwt>=2.8", "pyjwt>=2.8",
] ]
dev = [ dev = [
@ -132,7 +142,7 @@ dev = [
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py314" target-version = "py313"
src = ["src", "tests"] src = ["src", "tests"]
# Alembic migrations are autogenerated-style history; exclude them from lint+format. # Alembic migrations are autogenerated-style history; exclude them from lint+format.
extend-exclude = ["migrations"] extend-exclude = ["migrations"]

View file

@ -41,6 +41,7 @@ from src.contract_check.core.rate_limit import MemoryRateLimiter, RateLimiter, R
from src.contract_check.core.redis_client import get_redis_client from src.contract_check.core.redis_client import get_redis_client
from src.contract_check.core.s3.minio_storage import MinioStorage from src.contract_check.core.s3.minio_storage import MinioStorage
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
@asynccontextmanager @asynccontextmanager
@ -54,6 +55,7 @@ async def lifespan(app: FastAPI) -> Any:
) )
bind_context(service="api", env=settings.env) bind_context(service="api", env=settings.env)
init_sentry("api") init_sentry("api")
setup_telemetry("api")
storage = MinioStorage.from_endpoint_url( storage = MinioStorage.from_endpoint_url(
endpoint_url=settings.s3_endpoint_url, endpoint_url=settings.s3_endpoint_url,
@ -110,6 +112,7 @@ async def lifespan(app: FastAPI) -> Any:
await redis_client.aclose() await redis_client.aclose()
except Exception: except Exception:
pass pass
shutdown_telemetry()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -144,6 +147,15 @@ def create_app() -> FastAPI:
) -> RedirectResponse: ) -> RedirectResponse:
return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303) return RedirectResponse(url=f"/admin/login?reason={exc.reason}", status_code=303)
# Emit request traces to the OTLP collector. Must happen after all routes
# and middleware are registered so spans cover the full request lifecycle.
try:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
except Exception: # pragma: no cover - optional instrumentor
pass
return app return app

View file

@ -72,8 +72,7 @@ Readiness-проба. Выполняет `SELECT 1` в БД.
## metrics ## metrics
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации, если Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации.
`METRICS_BEARER_TOKEN` не задан; иначе требуется `Authorization: Bearer <token>`.
### `GET /metrics` ### `GET /metrics`

View file

@ -61,14 +61,7 @@ _STAGE_LABELS: dict[str, str] = {
"queued": "Документ принят. В очереди на обработку…", "queued": "Документ принят. В очереди на обработку…",
"extracting": "Извлекаю текст из документа…", "extracting": "Извлекаю текст из документа…",
"ocr": "Документ похож на скан — распознаю страницы (OCR)…", "ocr": "Документ похож на скан — распознаю страницы (OCR)…",
"prescreening": "Предварительная проверка договора…",
"analyzing": "Анализирую риски по чек-листу…", "analyzing": "Анализирую риски по чек-листу…",
"extracting_meta": "Извлекаю реквизиты договора…",
"queued_analyze": "Подготовка к глубокому анализу…",
"manual_review": "Отправлен на ручную проверку",
"auto_approved": "Готово (автопроверка)",
"done": "Готово",
"failed": "Ошибка обработки",
} }
@ -449,7 +442,7 @@ async def cmd_reports(message: Message, api: ApiClient) -> None:
filename = doc.get("filename") or "без имени" filename = doc.get("filename") or "без имени"
status = str(doc.get("status") or "unknown") status = str(doc.get("status") or "unknown")
stage = doc.get("stage") stage = doc.get("stage")
label = _stage_label_from(status, stage if isinstance(stage, str) else None) label = _STAGE_LABELS.get(str(stage or status), "Обрабатываю…")
lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}") lines.append(f"\n📄 {filename}\nID: {doc_id}\nСтатус: {label}")
lines.append("\nДля подробностей: /status <id>") lines.append("\nДля подробностей: /status <id>")
await message.answer("\n".join(lines)) await message.answer("\n".join(lines))
@ -640,12 +633,8 @@ async def _sleep(elapsed: float, timeout: float, interval: float) -> bool:
def _stage_label(report: ReportStatus) -> str: def _stage_label(report: ReportStatus) -> str:
return _stage_label_from(report.status, report.stage) stage = report.stage or report.status
return _STAGE_LABELS.get(stage, _STAGE_LABELS.get(report.status, "Обрабатываю…"))
def _stage_label_from(status: str | None, stage: str | None) -> str:
key = stage or status
return _STAGE_LABELS.get(key, _STAGE_LABELS.get(status, "Обрабатываю…"))
async def _deliver_report( async def _deliver_report(

View file

@ -7,7 +7,7 @@ Adapters (bot) deliberately do NOT import this package beyond config/logging
Subpackages: Subpackages:
config typed env config (pydantic-settings) config typed env config (pydantic-settings)
logging structlog JSON + correlation_id propagation logging structlog JSON + correlation_id propagation
telemetry no-op stubs; OTLP push removed telemetry OpenTelemetry init
sentry Sentry init sentry Sentry init
metrics Prometheus counters/histograms + metrics HTTP server metrics Prometheus counters/histograms + metrics HTTP server
db SQLAlchemy 2 models (6 tables), async session, enums db SQLAlchemy 2 models (6 tables), async session, enums

View file

@ -99,6 +99,7 @@ def _jwt_refresh_ttl() -> dt.timedelta:
def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str: def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
"""Sign a fresh access JWT for a verified user.""" """Sign a fresh access JWT for a verified user."""
settings = get_settings()
now = dt.datetime.now(tz=dt.UTC) now = dt.datetime.now(tz=dt.UTC)
claims = AccessTokenClaims( claims = AccessTokenClaims(
sub=user_id, sub=user_id,
@ -110,7 +111,7 @@ def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
{ {
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"exp": int((now + _jwt_access_ttl()).timestamp()), "exp": int((now + _jwt_access_ttl()).timestamp()),
"iss": "contract-check", "iss": settings.otel_service_name or "contract-check",
"aud": "contract-check", "aud": "contract-check",
} }
) )
@ -181,6 +182,7 @@ class RefreshTokenClaims:
def create_refresh_token(user_id: uuid.UUID, jti: str) -> str: def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
"""Sign a refresh JWT. `jti` is the lookup key in the refresh-token store.""" """Sign a refresh JWT. `jti` is the lookup key in the refresh-token store."""
settings = get_settings()
now = dt.datetime.now(tz=dt.UTC) now = dt.datetime.now(tz=dt.UTC)
claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH) claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH)
payload = claims.to_dict() payload = claims.to_dict()
@ -188,7 +190,7 @@ def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
{ {
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"exp": int((now + _jwt_refresh_ttl()).timestamp()), "exp": int((now + _jwt_refresh_ttl()).timestamp()),
"iss": "contract-check", "iss": settings.otel_service_name or "contract-check",
"aud": "contract-check", "aud": "contract-check",
} }
) )

View file

@ -86,6 +86,8 @@ class Settings(BaseSettings):
# --- observability (empty disables) --- # --- observability (empty disables) ---
sentry_dsn: str = "" sentry_dsn: str = ""
otel_exporter_otlp_endpoint: str = ""
otel_service_name: str = "contract-check"
# --- LLM provider --- # --- LLM provider ---
llm_provider: str = "ollama_cloud" llm_provider: str = "ollama_cloud"

View file

@ -93,7 +93,7 @@ def safe_body(
if isinstance(body, Mapping | list): if isinstance(body, Mapping | list):
try: try:
text = json.dumps(redact_json(body), ensure_ascii=False, default=str) text = json.dumps(redact_json(body), ensure_ascii=False, default=str)
except TypeError, ValueError: except (TypeError, ValueError):
text = str(body) text = str(body)
return _truncate(text, max_chars) return _truncate(text, max_chars)
if isinstance(body, bytes): if isinstance(body, bytes):
@ -147,20 +147,13 @@ def http_log_event_hooks(
request.extensions[started_key] = time.perf_counter() request.extensions[started_key] = time.perf_counter()
if not is_debug_enabled(): if not is_debug_enabled():
return return
try:
body: bytes | str | None = request.content
except httpx.RequestNotRead:
# Streaming bodies (e.g. multipart file uploads) are never loaded
# into memory, so there is nothing to inspect without consuming
# the stream the actual send needs. Summarize instead of crashing.
body = "<streaming body not read>"
log.debug( log.debug(
"http_request", "http_request",
service=service, service=service,
method=request.method, method=request.method,
url=str(request.url), url=str(request.url),
headers=redact_headers(request.headers), headers=redact_headers(request.headers),
body=safe_body(body, request.headers.get("content-type"), max_payload_chars), body=safe_body(request.content, request.headers.get("content-type"), max_payload_chars),
) )
async def _log_response(response: httpx.Response) -> None: async def _log_response(response: httpx.Response) -> None:

View file

@ -183,8 +183,8 @@ def configure_logging(
logger_factory=structlog.stdlib.LoggerFactory(), logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True, cache_logger_on_first_use=True,
) )
# Route stdlib logging through structlog so third-party libs match our format. # Route stdlib logging through structlog so third-party libs match our format,
# Structlog events always reach the root StreamHandler configured below. # and structlog events reach any OTEL handlers attached to the root logger.
stdlib_handler = logging.StreamHandler(sys.stderr) stdlib_handler = logging.StreamHandler(sys.stderr)
stdlib_handler.setFormatter( stdlib_handler.setFormatter(
structlog.stdlib.ProcessorFormatter( structlog.stdlib.ProcessorFormatter(

View file

@ -13,17 +13,7 @@ def get_redis_client(redis_url: str) -> Any:
Callers own the connection lifecycle. The api creates one client at startup Callers own the connection lifecycle. The api creates one client at startup
and stores it in app.state.redis. and stores it in app.state.redis.
Maintenance notifications are disabled: they are a Redis Cloud feature and
self-hosted/Valkey servers reject ``CLIENT MAINT_NOTIFICATIONS`` with an
``unknown subcommand`` error, spamming debug logs.
""" """
from redis.asyncio import Redis as AsyncRedis from redis.asyncio import Redis as AsyncRedis
from redis.maint_notifications import MaintNotificationsConfig
maint_config = MaintNotificationsConfig(enabled=False) return AsyncRedis.from_url(redis_url, decode_responses=True)
return AsyncRedis.from_url(
redis_url,
decode_responses=True,
maint_notifications_config=maint_config,
)

View file

@ -48,7 +48,7 @@ def verify_password(plain: str, hashed: str) -> bool:
return _hasher.verify(hashed, plain) return _hasher.verify(hashed, plain)
except VerifyMismatchError: except VerifyMismatchError:
return False return False
except VerificationError, InvalidHash: except (VerificationError, InvalidHash):
return False return False
@ -56,7 +56,7 @@ def needs_rehash(hashed: str) -> bool:
"""True if the stored hash uses outdated params and should be re-hashed on next login.""" """True if the stored hash uses outdated params and should be re-hashed on next login."""
try: try:
return _hasher.check_needs_rehash(hashed) return _hasher.check_needs_rehash(hashed)
except InvalidHash, TypeError: except (InvalidHash, TypeError):
return False return False

View file

@ -28,4 +28,4 @@ def init_sentry(service_name: str | None = None) -> None:
traces_sample_rate=traces_sample_rate, traces_sample_rate=traces_sample_rate,
send_default_pii=False, send_default_pii=False,
) )
log.info("sentry_initialized", service=service_name or "contract-check") log.info("sentry_initialized", service=service_name or settings.otel_service_name)

View file

@ -1,117 +1,167 @@
"""Telemetry stubs after passive-collection refactor. """OpenTelemetry initialization.
The application no longer pushes OTLP traces, logs, or metrics. Logs are Sets up OTLP exporters for traces, logs, and metrics when
written to stdout/stderr by ``core/logging.py`` and collected by the `OTEL_EXPORTER_OTLP_ENDPOINT` is set; otherwise no-op. httpx auto-instrumentation
container runtime or a sidecar (Vector in the ``observer`` profile, is wired so Ollama Cloud calls appear as spans. Service entrypoints call
Promtail in the ``obs`` profile). Prometheus metrics stay in-process and `setup_telemetry()` early and `shutdown_telemetry()` on exit.
are scraped from service ``/metrics`` endpoints.
``setup_telemetry()`` and ``shutdown_telemetry()`` remain as safe no-op Auth headers are taken from `OTEL_EXPORTER_OTLP_HEADERS` by the OTLP exporters
entrypoints so service ``__main__`` modules do not all have to change at (no code-level parsing needed).
once. ``get_tracer()`` and ``get_meter()`` return lightweight no-op objects
so any remaining instrumentation calls do not raise. This module imports `opentelemetry` only services in the `obs` group import it
(see docs/ARCHITECTURE.md §4). mypy resolves it via the dev group.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any import logging
from typing import TYPE_CHECKING, Any
from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import get_logger
if TYPE_CHECKING:
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
log = get_logger(__name__)
_trace_provider: TracerProvider | None = None
_log_provider: LoggerProvider | None = None
_meter_provider: MeterProvider | None = None
class _NoopSpan: def setup_telemetry(service_name: str | None = None) -> None:
"""Span-like object that ignores all operations.""" """Initialize OTel tracing/logging/metrics. Safe to call when disabled."""
global _trace_provider, _log_provider, _meter_provider
settings = get_settings()
endpoint = settings.otel_exporter_otlp_endpoint
if not endpoint:
log.debug("otel_disabled", reason="no endpoint configured")
return
def end(self, _timestamp: Any = None) -> None: from opentelemetry._logs import set_logger_provider
return None from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.metrics import set_meter_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import set_tracer_provider
def set_attribute(self, _key: str, _value: Any) -> None: class StructlogOTLPLogHandler(LoggingHandler):
return None """OTEL handler that unwraps structlog event dicts.
def record_exception(self, _exception: BaseException, _attributes: Any = None) -> None: ``core/logging.py`` routes structlog through stdlib logging; the
return None resulting ``LogRecord`` carries the event dictionary in
``record.msg``. This handler extracts the ``event`` as the log body and
promotes the remaining fields to OTEL attributes so OpenObserve can
index them.
"""
def __enter__(self) -> _NoopSpan: _STRUCTLOG_INTERNAL_KEYS = frozenset({"_logger", "_name"})
return self
def __exit__(self, *args: object) -> None: def _translate(self, record: logging.LogRecord) -> Any:
return None # Detect structlog-wrapped records produced by
# ``ProcessorFormatter.wrap_for_formatter``.
if (
getattr(record, "_logger", None) is not None
and getattr(record, "_name", None) is not None
and isinstance(record.msg, (tuple, list))
and len(record.msg) == 1
and isinstance(record.msg[0], dict)
):
event_dict = record.msg[0]
# Work on a copy so the original record (used by stderr handler)
# is not mutated.
patched = logging.makeLogRecord(record.__dict__)
patched.msg = event_dict.get("event", "")
patched.args = ()
# Drop structlog internal bookkeeping copied from the original
# record so it does not leak into OTEL attributes.
for key in self._STRUCTLOG_INTERNAL_KEYS:
patched.__dict__.pop(key, None)
for key, value in event_dict.items():
if key in self._STRUCTLOG_INTERNAL_KEYS:
continue
if key not in patched.__dict__:
setattr(patched, key, value)
return super()._translate(patched)
return super()._translate(record)
resource = Resource.create({"service.name": service_name or settings.otel_service_name})
class _NoopTracer: # Traces. Exporters read OTEL_EXPORTER_OTLP_ENDPOINT and append the
"""Tracer-like object that returns no-op spans.""" # signal-specific path (/v1/traces, /v1/logs, /v1/metrics) automatically.
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
set_tracer_provider(trace_provider)
_trace_provider = trace_provider
def start_span(self, _name: str, _context: Any = None, _kind: Any = None) -> _NoopSpan: # Logs (stdlib logging → OTLP). ``configure_logging`` routes structlog
return _NoopSpan() # through the stdlib logging tree; this handler unwraps the structured
# event dicts so OpenObserve receives the message body and fields.
log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
set_logger_provider(log_provider)
_log_provider = log_provider
def start_as_current_span(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopSpan: otel_log_handler = StructlogOTLPLogHandler(logger_provider=log_provider)
return _NoopSpan() otel_log_handler.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
logging.getLogger().addHandler(otel_log_handler)
# Metrics infrastructure (OTel metrics → OTLP). Existing prometheus_client
# metrics are still scraped by Prometheus; use remote_write for those.
metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter())
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
set_meter_provider(meter_provider)
_meter_provider = meter_provider
class _NoopCounter: try:
"""Counter-like object that ignores add() calls.""" from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
def add(self, _amount: int | float, _attributes: Any = None) -> None: HTTPXClientInstrumentor().instrument()
return None except Exception as exc: # pragma: no cover - optional instrumentor
log.warning("otel_httpx_instrument_failed", error=str(exc))
log.info("otel_initialized", endpoint=endpoint)
class _NoopHistogram: # Stdlib log so the OTel handler exports a verification record too.
"""Histogram-like object that ignores record() calls.""" logging.getLogger("contract_check.telemetry").info(
"OpenTelemetry initialized for %s at %s",
def record(self, _amount: int | float, _attributes: Any = None) -> None: service_name or settings.otel_service_name,
return None endpoint,
)
class _NoopObservableGauge:
"""Observable gauge-like object that ignores callbacks."""
def __init__(self) -> None:
pass
class _NoopMeter:
"""Meter-like object that returns no-op instruments."""
def create_counter(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopCounter:
return _NoopCounter()
def create_histogram(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopHistogram:
return _NoopHistogram()
def create_up_down_counter(self, _name: str, *_args: Any, **_kwargs: Any) -> _NoopCounter:
return _NoopCounter()
def create_observable_gauge(
self, _name: str, *_args: Any, **_kwargs: Any
) -> _NoopObservableGauge:
return _NoopObservableGauge()
_TRACER = _NoopTracer()
_METER = _NoopMeter()
def setup_telemetry(_service_name: str | None = None) -> None:
"""No-op telemetry initialization retained for backward compatibility."""
return None
def shutdown_telemetry() -> None: def shutdown_telemetry() -> None:
"""No-op telemetry shutdown retained for backward compatibility.""" """Flush and shut down all OTel providers if initialized."""
return None global _trace_provider, _log_provider, _meter_provider
for provider in (_trace_provider, _log_provider, _meter_provider):
if provider is not None:
try:
provider.shutdown()
except Exception as exc: # pragma: no cover - shutdown must not raise
log.warning("otel_shutdown_failed", error=str(exc))
_trace_provider = None
_log_provider = None
_meter_provider = None
def get_tracer(_name: str | None = None) -> _NoopTracer: def get_tracer(name: str | None = None) -> Any:
"""Return a no-op tracer. """Return a tracer. Returns a no-op-safe tracer when OTel is absent."""
from opentelemetry import trace
The application no longer uses OpenTelemetry; any existing calls to return trace.get_tracer(name or "contract_check")
``start_span`` or ``start_as_current_span`` are silently ignored.
"""
return _TRACER
def get_meter(_name: str | None = None) -> _NoopMeter: def get_meter(name: str | None = None) -> Any:
"""Return a no-op meter. """Return a meter. Returns a no-op-safe meter when OTel is absent."""
from opentelemetry import metrics
The application no longer uses OpenTelemetry; any existing instrument return metrics.get_meter(name or "contract_check")
calls are silently ignored.
"""
return _METER

View file

@ -9,6 +9,7 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_analyze.consumer import AnalyzeConsumer from src.contract_check.worker_analyze.consumer import AnalyzeConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -24,6 +25,7 @@ async def main() -> None:
) )
bind_context(service="worker-analyze", env=settings.env) bind_context(service="worker-analyze", env=settings.env)
init_sentry("worker-analyze") init_sentry("worker-analyze")
setup_telemetry("worker-analyze")
start_metrics_server(9102) start_metrics_server(9102)
@ -53,6 +55,7 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -12,6 +12,7 @@ from src.contract_check.core.db.session import create_session_factory, dispose_e
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_billing.scheduler import run_tick from src.contract_check.worker_billing.scheduler import run_tick
log = get_logger(__name__) log = get_logger(__name__)
@ -29,6 +30,7 @@ async def main() -> None:
) )
bind_context(service="worker-billing", env=settings.env) bind_context(service="worker-billing", env=settings.env)
init_sentry("worker-billing") init_sentry("worker-billing")
setup_telemetry("worker-billing")
start_metrics_server(9105) start_metrics_server(9105)
@ -62,6 +64,7 @@ async def main() -> None:
if provider is not None: if provider is not None:
await provider.aclose() await provider.aclose()
await dispose_engine() await dispose_engine()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,6 +9,7 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_extract.consumer import ExtractConsumer from src.contract_check.worker_extract.consumer import ExtractConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -24,6 +25,7 @@ async def main() -> None:
) )
bind_context(service="worker-extract", env=settings.env) bind_context(service="worker-extract", env=settings.env)
init_sentry("worker-extract") init_sentry("worker-extract")
setup_telemetry("worker-extract")
start_metrics_server(9101) start_metrics_server(9101)
@ -53,6 +55,7 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,6 +9,7 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_notify.consumer import NotifyConsumer from src.contract_check.worker_notify.consumer import NotifyConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -24,6 +25,7 @@ async def main() -> None:
) )
bind_context(service="worker-notify", env=settings.env) bind_context(service="worker-notify", env=settings.env)
init_sentry("worker-notify") init_sentry("worker-notify")
setup_telemetry("worker-notify")
start_metrics_server(9103) start_metrics_server(9103)
@ -53,6 +55,7 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -9,6 +9,7 @@ from src.contract_check.core.config import get_settings
from src.contract_check.core.logging import bind_context, configure_logging, get_logger from src.contract_check.core.logging import bind_context, configure_logging, get_logger
from src.contract_check.core.metrics import start_metrics_server from src.contract_check.core.metrics import start_metrics_server
from src.contract_check.core.sentry import init_sentry from src.contract_check.core.sentry import init_sentry
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
from src.contract_check.worker_prescreen.consumer import PrescreenConsumer from src.contract_check.worker_prescreen.consumer import PrescreenConsumer
log = get_logger(__name__) log = get_logger(__name__)
@ -24,6 +25,7 @@ async def main() -> None:
) )
bind_context(service="worker-prescreen", env=settings.env) bind_context(service="worker-prescreen", env=settings.env)
init_sentry("worker-prescreen") init_sentry("worker-prescreen")
setup_telemetry("worker-prescreen")
start_metrics_server(9104) start_metrics_server(9104)
@ -53,6 +55,7 @@ async def main() -> None:
) )
finally: finally:
await consumer.stop() await consumer.stop()
shutdown_telemetry()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -28,11 +28,15 @@ from src.contract_check.worker_prescreen.extractor import (
from src.contract_check.worker_prescreen.extractor_heuristic import ( from src.contract_check.worker_prescreen.extractor_heuristic import (
EXTRACTOR_VERSION as HEURISTIC_VERSION, EXTRACTOR_VERSION as HEURISTIC_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_heuristic import HeuristicExtractor from src.contract_check.worker_prescreen.extractor_heuristic import (
HeuristicExtractor,
)
from src.contract_check.worker_prescreen.extractor_llm import ( from src.contract_check.worker_prescreen.extractor_llm import (
EXTRACTOR_VERSION as HYBRID_LLM_VERSION, EXTRACTOR_VERSION as HYBRID_LLM_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_llm import LLMPrescreenExtractor from src.contract_check.worker_prescreen.extractor_llm import (
LLMPrescreenExtractor,
)
log = get_logger(__name__) log = get_logger(__name__)

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api --no-install-project uv sync --frozen --no-default-groups --group api --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group bot --no-install-project uv sync --frozen --no-default-groups --group bot --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -13,13 +13,13 @@ WORKDIR /app
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
COPY README.md ./ COPY README.md ./
# Install only the analyze group (core + db/mq/s3/obs). No tesseract/pymupdf # Install only the analyze group (core + db/mq/s3/obs + otel-httpx). No
# — this image is the leanest LLM/I/O worker. # tesseract/pymupdf — this image is the leanest LLM/I/O worker.
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group analyze --no-install-project uv sync --frozen --no-default-groups --group analyze --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group billing --no-install-project uv sync --frozen --no-default-groups --group billing --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group extract --no-install-project uv sync --frozen --no-default-groups --group extract --no-install-project
# ─── Stage 2: runtime with tesseract-ocr + language packs ──────────────────── # ─── Stage 2: runtime with tesseract-ocr + language packs ────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -33,7 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr-rus \ tesseract-ocr-rus \
tesseract-ocr-eng \ tesseract-ocr-eng \
fonts-dejavu-core \ fonts-dejavu-core \
libmagic1t64 \ libmagic1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group notify --no-install-project uv sync --frozen --no-default-groups --group notify --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group prescreen --no-install-project uv sync --frozen --no-default-groups --group prescreen --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.14-slim-trixie FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,21 +1,15 @@
"""Integration-test fixtures using an isolated Docker Compose infrastructure. """Integration-test fixtures using the running Docker Compose infrastructure.
The test infra (postgres + redis + rabbitmq + minio) is brought up automatically Run `docker compose up -d` before executing integration tests. Migrations run
by the session-scoped `infra` fixture and torn down after the session. This once per session; a service token is seeded so adapter-style endpoints can
keeps integration tests from racing against the development service workers authenticate.
that also consume RabbitMQ queues.
To run against an already-running external infra instead, set
``INTEGRATION_TEST_USE_EXTERNAL_INFRA=1`` before invoking pytest.
""" """
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess import subprocess
import sys import sys
import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -38,113 +32,9 @@ pytestmark = pytest.mark.integration
_BOT_SERVICE_TOKEN = "it-test-bot-token" _BOT_SERVICE_TOKEN = "it-test-bot-token"
# Ports must match docker-compose.test.yml. _DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check"
_TEST_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:25432/contract_check" _AMQP_URL = "amqp://contract_check:contract_check@localhost:5672//"
_TEST_AMQP_URL = "amqp://contract_check:contract_check@localhost:6672//" _S3_URL = "http://localhost:9000"
_TEST_S3_URL = "http://localhost:10000"
_DB_URL = _TEST_DB_URL
_AMQP_URL = _TEST_AMQP_URL
_S3_URL = _TEST_S3_URL
_USE_EXTERNAL_INFRA = os.environ.get("INTEGRATION_TEST_USE_EXTERNAL_INFRA", "0") == "1"
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
cmd,
cwd=str(_repo_root()),
env={**os.environ},
check=False,
capture_output=True,
text=True,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
cmd,
output=result.stdout,
stderr=result.stderr,
)
return result
def _docker_compose_test(args: list[str]) -> list[str]:
# Use a dedicated project name so the test stack is independent from the
# development stack. Without this, `docker compose` treats the running dev
# services as "orphans" and returns a non-zero exit code.
return [
"docker",
"compose",
"-p",
"contract-check-test",
"-f",
str(_repo_root() / "docker-compose.test.yml"),
*args,
]
def _wait_for_healthy(service: str, *, deadline_seconds: int = 60) -> None:
"""Poll `docker compose ps` until `service` reports healthy."""
start = time.monotonic()
while time.monotonic() - start < deadline_seconds:
result = _run(
_docker_compose_test(["ps", service, "--format", "json"]),
check=False,
)
if result.returncode == 0 and result.stdout:
# `docker compose ps --format json` emits one JSON object per line.
for line in result.stdout.strip().splitlines():
try:
info = json.loads(line)
except json.JSONDecodeError:
continue
if info.get("Health") == "healthy":
return
if info.get("State") == "exited" and info.get("ExitCode") == 0:
# One-shot containers are also fine.
return
time.sleep(1)
raise RuntimeError(f"service {service} did not become healthy within {deadline_seconds}s")
def _start_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Bring up the test stack without --wait: minio-init-test is a one-shot
# container that exits after creating the bucket, which makes --wait fail.
_run(_docker_compose_test(["up", "-d"]))
# Wait for every persistent service to be healthy.
for service in ("postgres-test", "redis-test", "rabbitmq-test", "minio-test"):
_wait_for_healthy(service)
# postgres healthcheck can still race with alembic, so explicitly wait for
# the DB to accept connections.
_run(
_docker_compose_test(
[
"exec",
"-T",
"postgres-test",
"sh",
"-c",
"until pg_isready -U contract_check -d contract_check; do sleep 1; done",
]
)
)
def _stop_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Use --volumes to wipe test data between runs. Never use -v for external infra.
_run(_docker_compose_test(["down", "-v"]), check=False)
def _set_env() -> None: def _set_env() -> None:
@ -154,8 +44,8 @@ def _set_env() -> None:
"S3_ENDPOINT_URL": _S3_URL, "S3_ENDPOINT_URL": _S3_URL,
"S3_ACCESS_KEY": "contract_check", "S3_ACCESS_KEY": "contract_check",
"S3_SECRET_KEY": "contract_check", "S3_SECRET_KEY": "contract_check",
"S3_BUCKET": "contract-check-docs-test", "S3_BUCKET": "contract-check-docs",
"REDIS_URL": "redis://localhost:27379/0", "REDIS_URL": "redis://localhost:17379/0",
"OLLAMA_HOST": "http://localhost", "OLLAMA_HOST": "http://localhost",
"OLLAMA_API_KEY": "test", "OLLAMA_API_KEY": "test",
"JWT_SECRET": "it-test-jwt-secret-not-for-production", "JWT_SECRET": "it-test-jwt-secret-not-for-production",
@ -167,11 +57,6 @@ def _set_env() -> None:
# `timeout` event). Keep it short so streaming tests finish fast. # `timeout` event). Keep it short so streaming tests finish fast.
"SSE_POLL_INTERVAL_SECONDS": "0.25", "SSE_POLL_INTERVAL_SECONDS": "0.25",
"SSE_MAX_STREAM_SECONDS": "5", "SSE_MAX_STREAM_SECONDS": "5",
# CI has no .env, so the Settings default (true) would route
# ExtractHandler to prescreen.q instead of analyze.q. The prescreen
# stage is exercised directly in test_prescreen_worker.py; the
# extract-handler tests assert the analyze.q path.
"PRESCREEN_ENABLED": "false",
} }
for k, v in env_vars.items(): for k, v in env_vars.items():
os.environ[k] = v os.environ[k] = v
@ -182,19 +67,13 @@ def infra() -> Iterator[dict[str, str]]:
_set_env() _set_env()
get_settings.cache_clear() get_settings.cache_clear()
_start_test_infra() repo_root = Path(__file__).resolve().parents[2]
subprocess.run(
_run( [sys.executable, "-m", "alembic", "-c", str(repo_root / "alembic.ini"), "upgrade", "head"],
[ cwd=str(repo_root),
sys.executable, env={**os.environ},
"-m",
"alembic",
"-c",
str(_repo_root() / "alembic.ini"),
"upgrade",
"head",
],
check=True, check=True,
capture_output=False,
) )
factory = create_session_factory() factory = create_session_factory()
@ -222,12 +101,10 @@ def infra() -> Iterator[dict[str, str]]:
"s3_endpoint_url": _S3_URL, "s3_endpoint_url": _S3_URL,
"s3_access_key": "contract_check", "s3_access_key": "contract_check",
"s3_secret_key": "contract_check", "s3_secret_key": "contract_check",
"s3_bucket": "contract-check-docs-test",
"token": _BOT_SERVICE_TOKEN, "token": _BOT_SERVICE_TOKEN,
} }
get_settings.cache_clear() get_settings.cache_clear()
_stop_test_infra()
@pytest_asyncio.fixture @pytest_asyncio.fixture

View file

@ -35,7 +35,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket=infra["s3_bucket"], bucket="contract-check-docs",
) )

View file

@ -63,7 +63,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket=infra["s3_bucket"], bucket="contract-check-docs",
) )

View file

@ -8,7 +8,7 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
import time import time
from urllib.parse import urlencode, urlsplit, urlunsplit from urllib.parse import urlencode
import httpx import httpx
import pytest import pytest
@ -181,12 +181,7 @@ async def _strict_rate_limit_client() -> httpx.AsyncClient:
from contract_check.api.app import create_app from contract_check.api.app import create_app
from contract_check.core.config import get_settings from contract_check.core.config import get_settings
# Reuse the infra Redis (set by conftest `_set_env`, test-stack port); isolated_redis_url = "redis://localhost:17379/15"
# fall back to the dev-stack port for standalone local runs. DB 15 is
# isolated so concurrent/sequential tests do not share buckets.
base_url = os.environ.get("REDIS_URL", "redis://localhost:27379/0")
parts = urlsplit(base_url)
isolated_redis_url = urlunsplit((parts.scheme, parts.netloc, "/15", "", ""))
r = redis.from_url(isolated_redis_url) r = redis.from_url(isolated_redis_url)
await r.flushdb() await r.flushdb()
await r.aclose() await r.aclose()

View file

@ -39,7 +39,7 @@ def storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket=infra["s3_bucket"], bucket="contract-check-docs",
) )

View file

@ -100,7 +100,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket=infra["s3_bucket"], bucket="contract-check-docs",
) )

View file

@ -83,7 +83,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket=infra["s3_bucket"], bucket="contract-check-docs",
) )

View file

@ -1,50 +0,0 @@
"""Stage/status label mapping for Telegram bot UI.
Guards the regression where backend statuses like `prescreening` and stages
such as `extracting_meta` were not present in `_STAGE_LABELS`, so the bot
showed the generic "Обрабатываю…" for every document in the `/reports` list
and for `/status` replies.
"""
from __future__ import annotations
import pytest
from contract_check.bot.client import ReportStatus
from contract_check.bot.handlers import _stage_label, _stage_label_from
@pytest.mark.parametrize(
"status,stage,expected",
[
("queued", None, "Документ принят. В очереди на обработку…"),
("extracting", None, "Извлекаю текст из документа…"),
("extracting", "ocr", "Документ похож на скан — распознаю страницы (OCR)…"),
("prescreening", "extracting_meta", "Извлекаю реквизиты договора…"),
("prescreening", None, "Предварительная проверка договора…"),
("analyzing", "queued_analyze", "Подготовка к глубокому анализу…"),
("analyzing", None, "Анализирую риски по чек-листу…"),
("manual_review", "manual_review", "Отправлен на ручную проверку"),
("done", "auto_approved", "Готово (автопроверка)"),
("done", None, "Готово"),
("failed", "analyze", "Ошибка обработки"),
("unknown_future_status", None, "Обрабатываю…"),
("unknown", "unknown_stage", "Обрабатываю…"),
],
)
def test_stage_label_mapping(status: str, stage: str | None, expected: str) -> None:
report = ReportStatus(
document_id="d",
status=status,
stage=stage,
markdown=None,
filename=None,
)
assert _stage_label(report) == expected
def test_stage_label_from_uses_status_fallback_when_stage_unknown() -> None:
# Stage is unknown, but status maps to a known label.
assert _stage_label_from("analyzing", "surprise_stage") == "Анализирую риски по чек-листу…"
# Both stage and status are unknown.
assert _stage_label_from("surprise", "surprise") == "Обрабатываю…"

View file

@ -145,37 +145,3 @@ async def test_event_hooks_silent_without_debug(monkeypatch: pytest.MonkeyPatch)
await client.get("https://api.test/v1/things") await client.get("https://api.test/v1/things")
assert logs == [] assert logs == []
async def test_event_hooks_handle_streaming_multipart_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Multipart uploads stream their body; the request hook must not crash."""
monkeypatch.setattr(hhttp, "is_debug_enabled", lambda: True)
_config = structlog.get_config()
monkeypatch.setattr(
hhttp,
"get_logger",
lambda name: structlog.get_logger(name),
)
structlog.configure(
processors=_config["processors"],
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=_config["logger_factory"],
cache_logger_on_first_use=False,
)
with capture_logs() as logs:
transport = httpx.MockTransport(lambda request: httpx.Response(202, json={"ok": True}))
async with httpx.AsyncClient(
transport=transport,
event_hooks=http_log_event_hooks(service="test-svc"),
) as client:
response = await client.post(
"https://api.test/v1/documents",
files={"file": ("contract.pdf", b"%PDF-1.7 fake", "application/pdf")},
)
assert response.status_code == 202
request_entry = next(entry for entry in logs if entry.get("event") == "http_request")
assert request_entry["method"] == "POST"
assert request_entry["body"] == "<streaming body not read>"

View file

@ -1,8 +1,7 @@
"""Unit tests for telemetry after the passive-collection refactor. """Unit tests for OpenTelemetry integration.
The application no longer pushes OTLP. This module verifies that telemetry Covers the structlog-aware OTEL log handler that unwraps event dicts so
setup/shutdown are safe no-ops and that structured logging still reaches the OpenObserve receives structured attributes.
configured root handler.
""" """
from __future__ import annotations from __future__ import annotations
@ -12,7 +11,6 @@ import logging
import pytest import pytest
from contract_check.core.logging import configure_logging, get_logger from contract_check.core.logging import configure_logging, get_logger
from contract_check.core.telemetry import get_meter, get_tracer, setup_telemetry, shutdown_telemetry
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -24,64 +22,82 @@ def _reset_logging():
root.handlers.clear() root.handlers.clear()
def test_setup_telemetry_is_safe_noop() -> None: def test_structlog_otlp_handler_unwraps_event_dict(monkeypatch: pytest.MonkeyPatch) -> None:
"""setup_telemetry() runs without error and does not add an OTLP handler.""" """A structlog-wrapped record is converted to body + attributes."""
root = logging.getLogger()
before = list(root.handlers)
setup_telemetry("test")
try:
after = list(root.handlers)
# No new handlers are attached because OTLP is no longer initialized.
assert after == before
finally:
shutdown_telemetry()
def test_shutdown_telemetry_is_safe_noop() -> None:
"""shutdown_telemetry() can be called even when setup_telemetry() was skipped."""
shutdown_telemetry()
shutdown_telemetry() # idempotent
def test_otlp_endpoint_env_does_not_initialize_exporters(monkeypatch: pytest.MonkeyPatch) -> None:
"""A legacy OTLP endpoint env var must not cause the application to import OTel SDK."""
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318")
from contract_check.core.config import get_settings from contract_check.core.config import get_settings
from contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
# Settings explicitly ignores unknown env vars, but a stale env var must not monkeypatch.setattr(
# be picked up by telemetry code. Refresh the cached settings instance. get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
get_settings.cache_clear() )
root = logging.getLogger() # setup_telemetry defines the handler class when OTEL is imported.
before = list(root.handlers)
setup_telemetry("test") setup_telemetry("test")
root = logging.getLogger()
try: try:
after = list(root.handlers) otel_handler = next(
assert after == before h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler"
assert not any(type(h).__name__.startswith(("OTLP", "StructlogOTLP")) for h in after) )
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=5,
msg=({"event": "hello_openobserve", "foo": "bar", "count": 42},),
args=(),
exc_info=None,
)
record._logger = logging.getLogger("test.logger") # type: ignore[attr-defined]
record._name = "info" # type: ignore[attr-defined]
otel_record = otel_handler._translate(record)
assert otel_record.body == "hello_openobserve"
attrs = {k: v for k, v in otel_record.attributes.items() if not k.startswith("code.")}
assert attrs["foo"] == "bar"
assert attrs["count"] == 42
assert "_logger" not in attrs
assert "_name" not in attrs
finally: finally:
shutdown_telemetry() shutdown_telemetry()
get_settings.cache_clear() root.handlers = [h for h in root.handlers if type(h).__name__ != "StructlogOTLPLogHandler"]
def test_get_tracer_and_meter_are_safe_noop() -> None: def test_structlog_logs_reach_root_handlers() -> None:
"""Tracer/meter accessors return objects that do not require OTel SDK.""" """After configure_logging, structlog events propagate to stdlib handlers."""
tracer = get_tracer("test")
meter = get_meter("test")
# They should be truthy and accept the expected creation calls without raising.
assert tracer
assert meter
span = tracer.start_span("ignored")
span.end()
counter = meter.create_counter("ignored")
counter.add(1)
def test_configure_logging_delivers_events_to_root_stream_handler() -> None:
"""After configure_logging, structlog events propagate to the root StreamHandler."""
root = logging.getLogger() root = logging.getLogger()
assert any(isinstance(h, logging.StreamHandler) for h in root.handlers) assert (
any(type(h).__name__ in ("StreamHandler", "ProcessorFormatter") for h in root.handlers)
or root.handlers
)
log = get_logger("test") log = get_logger("test")
log.info("smoke_test", answer=42) log.info("smoke_test", answer=42)
@pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING"])
def test_otel_handler_respects_configured_log_level(
monkeypatch: pytest.MonkeyPatch, level: str
) -> None:
"""The OTLP handler level matches settings.log_level so DEBUG logs can reach OpenObserve."""
from contract_check.core.config import get_settings
from contract_check.core.logging import configure_logging
from contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
configure_logging(level, json_output=True, service="test-service", env="test")
monkeypatch.setattr(
get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
)
monkeypatch.setattr(get_settings(), "log_level", level)
setup_telemetry("test")
root = logging.getLogger()
try:
otel_handler = next(
h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler"
)
assert otel_handler.level == getattr(logging, level)
finally:
shutdown_telemetry()
root.handlers = [h for h in root.handlers if type(h).__name__ != "StructlogOTLPLogHandler"]

View file

@ -74,13 +74,8 @@ class FakeProvider(PaymentProvider):
async def factory(): async def factory():
import os import os
# Integration conftest (_set_env) already points DATABASE_URL at the test os.environ["DATABASE_URL"] = (
# infra (:25432); only fall back to the dev port when it is unset, and "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check"
# restore the previous value afterwards.
prev_url = os.environ.get("DATABASE_URL")
os.environ.setdefault(
"DATABASE_URL",
"postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check",
) )
os.environ["PLANS_ENABLED"] = "true" os.environ["PLANS_ENABLED"] = "true"
os.environ["YOOKASSA_ENABLED"] = "true" os.environ["YOOKASSA_ENABLED"] = "true"
@ -90,10 +85,7 @@ async def factory():
get_settings.cache_clear() get_settings.cache_clear()
f = create_session_factory() f = create_session_factory()
yield f yield f
if prev_url is None: os.environ.pop("DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = prev_url
os.environ.pop("PLANS_ENABLED", None) os.environ.pop("PLANS_ENABLED", None)
os.environ.pop("YOOKASSA_ENABLED", None) os.environ.pop("YOOKASSA_ENABLED", None)
os.environ.pop("YOOKASSA_SHOP_ID", None) os.environ.pop("YOOKASSA_SHOP_ID", None)

1498
uv.lock generated

File diff suppressed because it is too large Load diff