Compare commits
No commits in common. "c4b22be773d061e22effce37e4407417aff5042e" and "2d5c2b50c0036632ed0b0e7d62deab38e39f2c9d" have entirely different histories.
c4b22be773
...
2d5c2b50c0
53 changed files with 1666 additions and 16884 deletions
34
.env.example
34
.env.example
|
|
@ -48,16 +48,22 @@ REFUND_FULL_USAGE_THRESHOLD=0.20 # ≤20% consumed + within window ⇒ full ref
|
|||
|
||||
# --- Observability (leave empty to disable) ---
|
||||
SENTRY_DSN=
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT is no longer used. The application does not push
|
||||
# OTLP; Vector/OpenObserve collects logs/metrics passively from stdout and
|
||||
# /metrics endpoints. Remove this line from existing .env files.
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=
|
||||
# OTLP endpoint for traces/metrics/logs.
|
||||
# profile: observer → http://otel-collector:4318 (OTLP/HTTP via collector)
|
||||
# profile: obs → configure your own otel-collector/tempo, or leave empty
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
# 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) ---
|
||||
# Vector (profile `observer`) authenticates to OpenObserve with these
|
||||
# credentials, so keep them in sync with the OpenObserve root user below.
|
||||
OPENOBSERVE_PORT=5080
|
||||
OPENOBSERVE_GRPC_PORT=5081
|
||||
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
|
||||
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
|
||||
# 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_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_PORT=3000
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
|
|
@ -109,16 +120,9 @@ CHUNK_SIZE_CHARS=10000
|
|||
# --- API (FastAPI) ---
|
||||
API_HOST=0.0.0.0
|
||||
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
|
||||
B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency
|
||||
# Bearer token protecting /metrics. Generate a strong secret for production and
|
||||
# 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
|
||||
# Bearer token protecting /metrics. Leave empty to keep the endpoint open (default).
|
||||
METRICS_BEARER_TOKEN=
|
||||
CORS_ORIGINS= # comma-separated, future web SPA
|
||||
|
||||
|
|
|
|||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
# Integration tests.
|
||||
test-integration:
|
||||
name: Integration tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# test-integration:
|
||||
# name: Integration tests
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: uv.lock
|
||||
# - name: Setup uv
|
||||
# uses: astral-sh/setup-uv@v8.3.2
|
||||
# with:
|
||||
# enable-cache: true
|
||||
# cache-dependency-glob: uv.lock
|
||||
|
||||
- name: Install Python
|
||||
run: uv python install
|
||||
# - name: Install Python
|
||||
# run: uv python install
|
||||
|
||||
- name: Sync dev dependencies
|
||||
run: uv sync --group dev --frozen
|
||||
# - name: Sync dev dependencies
|
||||
# run: uv sync --group dev --frozen
|
||||
|
||||
# No --wait: minio-init-test is a one-shot container that exits (0) after
|
||||
# creating the bucket, and --wait treats any exited container as failure.
|
||||
# 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: Start infrastructure (postgres/redis/rabbitmq/minio)
|
||||
# run: docker compose up -d --wait
|
||||
|
||||
- name: Run integration tests
|
||||
run: uv run pytest -m integration
|
||||
# - name: Run integration tests
|
||||
# run: uv run pytest -m integration
|
||||
|
||||
- name: Teardown infrastructure
|
||||
if: always()
|
||||
run: docker compose -p contract-check-test -f docker-compose.test.yml down -v
|
||||
# - name: Teardown infrastructure
|
||||
# if: always()
|
||||
# run: docker compose down -v
|
||||
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ repos:
|
|||
exclude: ^deploy/vps/docker-compose\.override\.example\.yml$
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
exclude: ^docs/
|
||||
- id: check-merge-conflict
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.14
|
||||
3.13
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -132,13 +132,13 @@ obs-url: ## Print Grafana URL and default credentials
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
observer-down: ## Stop OpenObserve + Vector containers
|
||||
observer-down: ## Stop OpenObserve + otel-collector containers
|
||||
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
|
||||
|
||||
obs-reset: ## Reset Grafana and Loki volumes (wipes dashboards/logs data)
|
||||
|
|
|
|||
|
|
@ -143,12 +143,11 @@ docker compose --profile bot up -d --build # + bot (можно запус
|
|||
- `services` — api + worker-ы (без бота).
|
||||
- `bot` — Telegram-бот; можно поднять на этом же хосте или на отдельном сервере (`docs/DEPLOY.md` §14).
|
||||
- `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`,
|
||||
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`
|
||||
(по умолчанию только на loopback), OpenObserve UI `5080`, edge `80`/`443`.
|
||||
Метрики worker-ов больше не публикуются на хост; их скрейпит Vector внутри сети compose.
|
||||
RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`,
|
||||
worker metrics: extract `9101`, analyze `9102`, notify `9103`, prescreen `9104`, billing `9105`, edge `80`/`443`.
|
||||
|
||||
## API (кратко)
|
||||
|
||||
|
|
|
|||
70
deploy/observability/otel-collector-config.yaml
Normal file
70
deploy/observability/otel-collector-config.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -150,9 +150,10 @@ services:
|
|||
REDIS_URL: redis://redis:6379/0
|
||||
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
OTEL_SERVICE_NAME: api
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
- "${API_METRICS_BIND_HOST:-127.0.0.1}:${API_METRICS_PORT:-9100}:9100"
|
||||
- "${API_METRICS_PORT:-9100}:9100"
|
||||
healthcheck:
|
||||
test:
|
||||
- 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}
|
||||
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
OTEL_SERVICE_NAME: worker-extract
|
||||
ports:
|
||||
- "9101:9101"
|
||||
|
||||
worker-analyze:
|
||||
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}
|
||||
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
OTEL_SERVICE_NAME: worker-analyze
|
||||
ports:
|
||||
- "9102:9102"
|
||||
|
||||
worker-prescreen:
|
||||
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}
|
||||
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||
S3_ENDPOINT_URL: http://minio:9000
|
||||
OTEL_SERVICE_NAME: worker-prescreen
|
||||
ports:
|
||||
- "9104:9104"
|
||||
|
||||
worker-billing:
|
||||
profiles: ["services"]
|
||||
|
|
@ -257,6 +267,9 @@ services:
|
|||
required: false
|
||||
environment:
|
||||
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:
|
||||
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}
|
||||
RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/}
|
||||
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
|
||||
# the bot holds no DB/MQ/S3 credentials — it talks only to the API.
|
||||
|
|
@ -383,16 +399,16 @@ services:
|
|||
# ── OBSERVABILITY ──────────────────────────────────────────────────────────
|
||||
# Two observability stacks are provided; activate exactly one profile.
|
||||
#
|
||||
# profile: observer → OpenObserve via Vector (lightweight, single binary).
|
||||
# Vector scrapes container logs from the Docker socket
|
||||
# and scrapes service /metrics endpoints; application
|
||||
# code does not push telemetry.
|
||||
# profile: observer → OpenObserve (lightweight, single binary).
|
||||
# Use for dev/homelab or when hardware is tight.
|
||||
# profile: obs → Grafana + Prometheus + Loki (mature, heavier).
|
||||
# Use for production-grade visibility.
|
||||
#
|
||||
# Vector (profile `observer`) authenticates to OpenObserve with
|
||||
# OPENOBSERVE_ROOT_USER_EMAIL / OPENOBSERVE_ROOT_USER_PASSWORD. No OTLP
|
||||
# endpoint configuration is needed in the app.
|
||||
# App services send OTLP traces/metrics/logs to the endpoint configured in
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at otel-collector:4318 for the
|
||||
# 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) ───────────────────────────
|
||||
openobserve:
|
||||
|
|
@ -413,7 +429,8 @@ services:
|
|||
volumes:
|
||||
- openobserve-data:/data
|
||||
ports:
|
||||
- "${OPENOBSERVE_PORT:-5080}:5080" # UI + ingestion endpoints
|
||||
- "${OPENOBSERVE_PORT:-5080}:5080" # UI + OTLP/HTTP
|
||||
- "${OPENOBSERVE_GRPC_PORT:-5081}:5081" # OTLP/gRPC
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
|
@ -423,18 +440,20 @@ services:
|
|||
cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}"
|
||||
memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}"
|
||||
|
||||
vector:
|
||||
otel-collector:
|
||||
profiles: ["observer"]
|
||||
image: timberio/vector:0.43.0-alpine
|
||||
container_name: contract_check-vector
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
container_name: contract_check-otel-collector
|
||||
restart: unless-stopped
|
||||
command: ["--config", "/etc/otelcol/config.yaml"]
|
||||
environment:
|
||||
OPENOBSERVE_ROOT_USER_EMAIL: ${OPENOBSERVE_ROOT_USER_EMAIL:-root@example.com}
|
||||
OPENOBSERVE_ROOT_USER_PASSWORD: ${OPENOBSERVE_ROOT_USER_PASSWORD:-Complexpass#123}
|
||||
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
|
||||
OPENOBSERVE_AUTH_TOKEN: ${OPENOBSERVE_AUTH_TOKEN}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./deploy/observability/vector-config.yaml:/etc/vector/vector.yaml:ro
|
||||
- ./deploy/observability/otel-collector-config.yaml:/etc/otelcol/config.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:
|
||||
openobserve:
|
||||
condition: service_started
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
> **workers** (extract/OCR, deterministic prescreen, and LLM-analyze, split for
|
||||
> CPU vs I/O profiles), a Telegram **bot** adapter, backed by
|
||||
> **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
|
||||
> (`srv/` — api + three workers + bot + prototype), shared `contract_check.core`
|
||||
> 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
|
||||
a failure-class taxonomy; credit refund stays idempotent.
|
||||
- **Observability is full**, not logs-only: structlog+correlation_id, Sentry,
|
||||
Prometheus+Grafana; the lightweight `observer` profile uses Vector+OpenObserve.
|
||||
OpenTelemetry push has been removed in favour of passive collection.
|
||||
Prometheus+Grafana, OpenTelemetry.
|
||||
- **Payments (ЮKassa) live** behind a provider port (`core/billing/port.py`),
|
||||
webhook-driven state machine, plans/subscriptions + credit top-ups, and a
|
||||
`worker-billing` scheduler (renewals/expiry/reconciliation). Disabled by
|
||||
|
|
@ -130,14 +129,14 @@ Control plane:
|
|||
| Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan |
|
||||
| Report storage | JSONB + markdown column | — |
|
||||
| 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 |
|
||||
| Refund | Policy switch `all\|infra_only` + failure classes | refund-all |
|
||||
| Edge | Nginx + certbot | — |
|
||||
| Deploy | Compose now, k8s-ready later | — |
|
||||
| Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark |
|
||||
| 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 | — |
|
||||
|
||||
---
|
||||
|
|
@ -168,7 +167,7 @@ DealDocumentScreening/
|
|||
│ │ └── certbot-init.sh (initial cert + nginx reload)
|
||||
│ └── observability/
|
||||
│ ├── 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)
|
||||
│ ├── tempo.yaml (planned — trace storage for Grafana stack)
|
||||
│ └── grafana/provisioning/
|
||||
|
|
@ -186,7 +185,7 @@ DealDocumentScreening/
|
|||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py (pydantic-settings: base + per-service, §11)
|
||||
│ │ ├── 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)
|
||||
│ │ ├── metrics.py (prometheus_client registry + counters/hists)
|
||||
│ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest)
|
||||
|
|
@ -1029,7 +1028,7 @@ change.
|
|||
### Volumes & restart
|
||||
|
||||
- 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.
|
||||
- `depends_on: condition: service_healthy` everywhere with real healthchecks
|
||||
(pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`,
|
||||
|
|
@ -1087,11 +1086,12 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
| `REFUND_WINDOW_DAYS` | `14` | full-refund window (§8a) |
|
||||
| `REFUND_FULL_USAGE_THRESHOLD` | `0.20` | usage ratio for full refund |
|
||||
| `SENTRY_DSN` | (empty) | if set, init sentry |
|
||||
| `OPENOBSERVE_ROOT_USER_EMAIL` | `root@example.com` | OpenObserve root user; Vector uses it for Basic auth |
|
||||
| `OPENOBSERVE_ROOT_USER_PASSWORD` | `Complexpass#123` | OpenObserve root password; Vector uses it for Basic auth |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | (removed) | no longer used; the application does not push OTLP |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | (removed) | no longer used |
|
||||
| `OTEL_SERVICE_NAME` | (removed) | service name is set by `configure_logging()` and compose labels |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | (empty) | OTLP endpoint; with profile `observer` use `http://otel-collector:4318` |
|
||||
| `OTEL_SERVICE_NAME` | per-service | overridden in each service settings |
|
||||
| `OPENOBSERVE_AUTH_TOKEN` | default creds base64 | Basic auth token the collector uses to forward to OpenObserve |
|
||||
| `OTEL_COLLECTOR_OTLP_GRPC_PORT` | `4317` | host port for collector OTLP/gRPC receiver |
|
||||
| `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_ADMIN_USER` | `admin` | initial Grafana admin user |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | `admin` | initial Grafana admin password |
|
||||
|
|
@ -1141,10 +1141,9 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
|---|---|---|
|
||||
| `API_HOST` | `0.0.0.0` | |
|
||||
| `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` | host port the API /metrics endpoint is published on (compose only; default loopback) |
|
||||
| `API_METRICS_PORT` | `9100` | |
|
||||
| `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) | |
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
(shown for api):
|
||||
|
||||
```dockerfile
|
||||
# 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 \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||
WORKDIR /app
|
||||
|
|
@ -1202,7 +1201,7 @@ COPY src ./src
|
|||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
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
|
||||
WORKDIR /app
|
||||
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 |
|
||||
|---|---|---|---|---|
|
||||
| `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-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 |
|
||||
| `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
|
||||
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.
|
||||
|
||||
### pyproject.toml dependency-groups (actual — PEP 735)
|
||||
|
|
@ -1240,7 +1239,7 @@ that need them. Sketch (see `pyproject.toml` for the authoritative list):
|
|||
```toml
|
||||
[project]
|
||||
name = "contract-check"
|
||||
requires-python = ">=3.14"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1",
|
||||
"python-dotenv>=1.0", "httpx[http2]>=0.27",
|
||||
|
|
@ -1250,19 +1249,23 @@ dependencies = [
|
|||
db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"]
|
||||
mq = ["aio-pika>=9.4"]
|
||||
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" },
|
||||
{ include-group = "s3" }, { include-group = "obs" },
|
||||
"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" },
|
||||
{ include-group = "s3" }, { include-group = "obs" },
|
||||
"pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10",
|
||||
"mammoth>=1.8", "striprtf>=0.0.26", "chardet>=5.2",
|
||||
"python-magic>=0.4.27"]
|
||||
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" },
|
||||
{ include-group = "s3" }, { include-group = "obs" }]
|
||||
bot = ["aiogram>=3.4"]
|
||||
|
|
@ -1280,8 +1283,8 @@ One file, profiles. Default (`docker compose up`) = infra only.
|
|||
- `services` profile = api + workers (no bot).
|
||||
- `bot` profile = Telegram bot adapter; can run on the same host or a separate
|
||||
server (`docs/DEPLOY.md` §14).
|
||||
- `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + Vector)
|
||||
are mutually exclusive observability stacks.
|
||||
- `obs` (Grafana + Loki + Prometheus) and `observer` (OpenObserve + OTel
|
||||
Collector) are mutually exclusive observability stacks.
|
||||
- `edge` = Nginx + certbot.
|
||||
|
||||
```
|
||||
|
|
@ -1313,9 +1316,9 @@ services:
|
|||
prometheus: prom/prometheus; scrapes service /metrics endpoints
|
||||
|
||||
# ── OBSERVABILITY — OPENOBSERVE STACK (profile: observer) ──
|
||||
openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + JSON/Prometheus-write backend on :5080
|
||||
vector: timberio/vector:0.43.0-alpine; reads Docker logs + scrapes /metrics,
|
||||
forwards logs/metrics to OpenObserve
|
||||
openobserve: public.ecr.aws/zinclabs/openobserve:latest; UI + OTLP backend on :5080, gRPC on :5081
|
||||
otel-collector: otel/opentelemetry-collector-contrib:latest; OTLP receiver 4317/4318,
|
||||
scrapes /metrics from services, forwards logs/metrics/traces to OpenObserve
|
||||
|
||||
# ── EDGE (profile: edge) ──
|
||||
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
|
||||
contextvar before handling. So a single upload's logs trace
|
||||
api → rabbit → worker-extract → worker-analyze → DB under one ID.
|
||||
- Distributed tracing is intentionally out of scope; `correlation_id` in
|
||||
structlog already provides request-level correlation across services.
|
||||
- OTel baggage/span context propagates the same ID for distributed traces.
|
||||
- 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
|
||||
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`
|
||||
metrics exposed by the services.
|
||||
- Tracing may be added later as a separate feature evaluated against the
|
||||
passive-collection principle.
|
||||
- Tempo will receive OTLP traces via the OpenTelemetry collector when added.
|
||||
- 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
|
||||
local Docker socket and scrapes Prometheus `/metrics` endpoints inside the
|
||||
compose network (`api:8000/metrics`, workers on `:9101`/`:9102`/`:9104`/`:9105`/`:9103`).
|
||||
- Vector enriches logs with `service` (container name) and preserves
|
||||
`correlation_id` from JSON log lines.
|
||||
- Logs are sent to
|
||||
`http://openobserve:5080/openobserve/api/default/contract_check/_json` and
|
||||
metrics to `http://openobserve:5080/openobserve/api/default/prometheus/api/v1/write`,
|
||||
both authenticated with the OpenObserve root credentials
|
||||
(`OPENOBSERVE_ROOT_USER_EMAIL` / `OPENOBSERVE_ROOT_USER_PASSWORD`).
|
||||
- `otel-collector` runs in the `observer` profile and listens for OTLP on
|
||||
`4317` (gRPC) and `4318` (HTTP).
|
||||
- Services export OTLP traces, metrics, and logs to `http://otel-collector:4318`
|
||||
(`OTEL_EXPORTER_OTLP_ENDPOINT`).
|
||||
- The collector also scrapes Prometheus `/metrics` from `api:9100` and each
|
||||
worker (`:9101`/`:9102`/`:9104`/`:9105`/`:9103`).
|
||||
- `otlphttp/openobserve` exporter forwards logs and traces to
|
||||
`http://openobserve:5080/openobserve/api/default` using `OPENOBSERVE_AUTH_TOKEN`
|
||||
(the `/openobserve` prefix matches `ZO_BASE_URI`).
|
||||
- `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.
|
||||
- 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`)
|
||||
|
||||
- `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.
|
||||
### OpenTelemetry (`core/telemetry.py`)
|
||||
|
||||
- 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.
|
||||
- **Publisher confirms** on; a published-but-unconfirmed message fails the
|
||||
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).
|
||||
- React SPA + Telegram Login + SSE/webhook delivery.
|
||||
- Grafana dashboards polished.
|
||||
- Grafana dashboards polished; Tempo/Jaeger trace UI.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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^(attempt−1)` (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 0001–0011)
|
||||
|
||||
| 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/`.
|
||||
|
|
@ -144,11 +144,9 @@ PRICE_PER_DOC_KOPECKS=19900 # 199 ₽ за документ без подпис
|
|||
|
||||
# --- Observability (опционально) ---
|
||||
SENTRY_DSN=https://...@sentry.io/...
|
||||
# Vector (профиль `observer`) авторизуется в OpenObserve с этими кредами
|
||||
# (совпадают с root-пользователем OpenObserve).
|
||||
OPENOBSERVE_ROOT_USER_EMAIL=root@example.com
|
||||
OPENOBSERVE_ROOT_USER_PASSWORD=Complexpass#123
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT больше не используется: приложение не шлёт OTLP.
|
||||
# OTLP/HTTP endpoint. With profile `observer` use the local collector.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||
OPENOBSERVE_AUTH_TOKEN=cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=
|
||||
```
|
||||
|
||||
> Полный список: см. `.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` |
|
||||
| `bot` | `bot` — Telegram-адаптер; можно поднять на этом же хосте или на отдельном сервере |
|
||||
| `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` |
|
||||
|
||||
```bash
|
||||
|
|
@ -222,7 +220,7 @@ docker compose --profile services --profile bot up -d --build
|
|||
# + observability Grafana/Loki/Prometheus (логи + метрики):
|
||||
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
|
||||
|
||||
# + edge (nginx reverse proxy + TLS; deploy/nginx готов):
|
||||
|
|
@ -307,9 +305,7 @@ docker compose --profile bot logs -f bot
|
|||
```bash
|
||||
curl http://localhost:8000/healthz # liveness — 200
|
||||
curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO)
|
||||
# /metrics доступен на loopback по умолчанию; снаружи хоста — только через
|
||||
# edge/nginx или по туннелю. Для проверки внутри контейнера:
|
||||
docker compose exec api curl -s http://localhost:8000/metrics
|
||||
curl http://localhost:8000/metrics # Prometheus exposition
|
||||
```
|
||||
|
||||
### 6.2 RabbitMQ Management UI
|
||||
|
|
@ -634,8 +630,7 @@ curl https://contract-check.example.com/healthz
|
|||
По умолчанию `nginx.conf` проксирует:
|
||||
- `/api/v1/*`, `/admin/*`
|
||||
- `/healthz`, `/readyz`
|
||||
- `/metrics` → проксируется наружу; настройте `METRICS_BEARER_TOKEN` и ограничьте доступ
|
||||
файрволом/edge, чтобы метрики не утекали наружу. Worker-метрики доступны только внутри сети compose.
|
||||
- `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг)
|
||||
- `/webhook/*` → `/api/v1/webhooks/`
|
||||
- `${BOT_WEBHOOK_PATH}` → `bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
|
||||
- `/grafana/*` → `grafana:3000` (когда поднят профиль `obs`)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ name = "contract-check"
|
|||
version = "0.1.0"
|
||||
description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
requires-python = ">=3.13"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [{ name = "Контракт-чек" }]
|
||||
keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"]
|
||||
|
|
@ -32,7 +32,7 @@ packages = ["src/contract_check"]
|
|||
#
|
||||
# api → core + db/mq/s3/obs + fastapi/uvicorn
|
||||
# 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)
|
||||
# dev → everything needed to lint/typecheck/test locally
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -51,6 +51,8 @@ s3 = [
|
|||
obs = [
|
||||
"prometheus-client>=0.20",
|
||||
"sentry-sdk>=2",
|
||||
"opentelemetry-sdk>=1.24",
|
||||
"opentelemetry-exporter-otlp>=1.24",
|
||||
]
|
||||
api = [
|
||||
{ include-group = "db" },
|
||||
|
|
@ -66,6 +68,9 @@ api = [
|
|||
"argon2-cffi>=23.1",
|
||||
"email-validator>=2.1",
|
||||
"webauthn>=2.5",
|
||||
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
||||
"opentelemetry-instrumentation-asgi>=0.45b0",
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
]
|
||||
extract = [
|
||||
{ include-group = "db" },
|
||||
|
|
@ -79,18 +84,21 @@ extract = [
|
|||
"striprtf>=0.0.26",
|
||||
"chardet>=5.2",
|
||||
"python-magic>=0.4.27",
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
]
|
||||
analyze = [
|
||||
{ include-group = "db" },
|
||||
{ include-group = "mq" },
|
||||
{ include-group = "s3" },
|
||||
{ include-group = "obs" },
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
]
|
||||
prescreen = [
|
||||
{ include-group = "db" },
|
||||
{ include-group = "mq" },
|
||||
{ include-group = "s3" },
|
||||
{ include-group = "obs" },
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
]
|
||||
bot = [
|
||||
"aiogram>=3.4",
|
||||
|
|
@ -104,10 +112,12 @@ notify = [
|
|||
{ include-group = "mq" },
|
||||
{ include-group = "obs" },
|
||||
"aiosmtplib>=3.0",
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
]
|
||||
billing = [
|
||||
{ include-group = "db" },
|
||||
{ include-group = "obs" },
|
||||
"opentelemetry-instrumentation-httpx>=0.45b0",
|
||||
"pyjwt>=2.8",
|
||||
]
|
||||
dev = [
|
||||
|
|
@ -132,7 +142,7 @@ dev = [
|
|||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py314"
|
||||
target-version = "py313"
|
||||
src = ["src", "tests"]
|
||||
# Alembic migrations are autogenerated-style history; exclude them from lint+format.
|
||||
extend-exclude = ["migrations"]
|
||||
|
|
|
|||
|
|
@ -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.s3.minio_storage import MinioStorage
|
||||
from src.contract_check.core.sentry import init_sentry
|
||||
from src.contract_check.core.telemetry import setup_telemetry, shutdown_telemetry
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -54,6 +55,7 @@ async def lifespan(app: FastAPI) -> Any:
|
|||
)
|
||||
bind_context(service="api", env=settings.env)
|
||||
init_sentry("api")
|
||||
setup_telemetry("api")
|
||||
|
||||
storage = MinioStorage.from_endpoint_url(
|
||||
endpoint_url=settings.s3_endpoint_url,
|
||||
|
|
@ -110,6 +112,7 @@ async def lifespan(app: FastAPI) -> Any:
|
|||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
|
@ -144,6 +147,15 @@ def create_app() -> FastAPI:
|
|||
) -> RedirectResponse:
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,7 @@ Readiness-проба. Выполняет `SELECT 1` в БД.
|
|||
|
||||
## metrics
|
||||
|
||||
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации, если
|
||||
`METRICS_BEARER_TOKEN` не задан; иначе требуется `Authorization: Bearer <token>`.
|
||||
Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации.
|
||||
|
||||
### `GET /metrics`
|
||||
|
||||
|
|
|
|||
|
|
@ -61,14 +61,7 @@ _STAGE_LABELS: dict[str, str] = {
|
|||
"queued": "Документ принят. В очереди на обработку…",
|
||||
"extracting": "Извлекаю текст из документа…",
|
||||
"ocr": "Документ похож на скан — распознаю страницы (OCR)…",
|
||||
"prescreening": "Предварительная проверка договора…",
|
||||
"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 "без имени"
|
||||
status = str(doc.get("status") or "unknown")
|
||||
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("\nДля подробностей: /status <id>")
|
||||
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:
|
||||
return _stage_label_from(report.status, report.stage)
|
||||
|
||||
|
||||
def _stage_label_from(status: str | None, stage: str | None) -> str:
|
||||
key = stage or status
|
||||
return _STAGE_LABELS.get(key, _STAGE_LABELS.get(status, "Обрабатываю…"))
|
||||
stage = report.stage or report.status
|
||||
return _STAGE_LABELS.get(stage, _STAGE_LABELS.get(report.status, "Обрабатываю…"))
|
||||
|
||||
|
||||
async def _deliver_report(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Adapters (bot) deliberately do NOT import this package beyond config/logging
|
|||
Subpackages:
|
||||
config — typed env config (pydantic-settings)
|
||||
logging — structlog JSON + correlation_id propagation
|
||||
telemetry — no-op stubs; OTLP push removed
|
||||
telemetry — OpenTelemetry init
|
||||
sentry — Sentry init
|
||||
metrics — Prometheus counters/histograms + metrics HTTP server
|
||||
db — SQLAlchemy 2 models (6 tables), async session, enums
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ def _jwt_refresh_ttl() -> dt.timedelta:
|
|||
|
||||
def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
|
||||
"""Sign a fresh access JWT for a verified user."""
|
||||
settings = get_settings()
|
||||
now = dt.datetime.now(tz=dt.UTC)
|
||||
claims = AccessTokenClaims(
|
||||
sub=user_id,
|
||||
|
|
@ -110,7 +111,7 @@ def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str:
|
|||
{
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + _jwt_access_ttl()).timestamp()),
|
||||
"iss": "contract-check",
|
||||
"iss": settings.otel_service_name or "contract-check",
|
||||
"aud": "contract-check",
|
||||
}
|
||||
)
|
||||
|
|
@ -181,6 +182,7 @@ class RefreshTokenClaims:
|
|||
|
||||
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."""
|
||||
settings = get_settings()
|
||||
now = dt.datetime.now(tz=dt.UTC)
|
||||
claims = RefreshTokenClaims(sub=user_id, jti=jti, type=JWT_TYPE_REFRESH)
|
||||
payload = claims.to_dict()
|
||||
|
|
@ -188,7 +190,7 @@ def create_refresh_token(user_id: uuid.UUID, jti: str) -> str:
|
|||
{
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + _jwt_refresh_ttl()).timestamp()),
|
||||
"iss": "contract-check",
|
||||
"iss": settings.otel_service_name or "contract-check",
|
||||
"aud": "contract-check",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ class Settings(BaseSettings):
|
|||
|
||||
# --- observability (empty disables) ---
|
||||
sentry_dsn: str = ""
|
||||
otel_exporter_otlp_endpoint: str = ""
|
||||
otel_service_name: str = "contract-check"
|
||||
|
||||
# --- LLM provider ---
|
||||
llm_provider: str = "ollama_cloud"
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ def safe_body(
|
|||
if isinstance(body, Mapping | list):
|
||||
try:
|
||||
text = json.dumps(redact_json(body), ensure_ascii=False, default=str)
|
||||
except TypeError, ValueError:
|
||||
except (TypeError, ValueError):
|
||||
text = str(body)
|
||||
return _truncate(text, max_chars)
|
||||
if isinstance(body, bytes):
|
||||
|
|
@ -147,20 +147,13 @@ def http_log_event_hooks(
|
|||
request.extensions[started_key] = time.perf_counter()
|
||||
if not is_debug_enabled():
|
||||
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(
|
||||
"http_request",
|
||||
service=service,
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -183,8 +183,8 @@ def configure_logging(
|
|||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
# Route stdlib logging through structlog so third-party libs match our format.
|
||||
# Structlog events always reach the root StreamHandler configured below.
|
||||
# Route stdlib logging through structlog so third-party libs match our format,
|
||||
# and structlog events reach any OTEL handlers attached to the root logger.
|
||||
stdlib_handler = logging.StreamHandler(sys.stderr)
|
||||
stdlib_handler.setFormatter(
|
||||
structlog.stdlib.ProcessorFormatter(
|
||||
|
|
|
|||
|
|
@ -13,17 +13,7 @@ def get_redis_client(redis_url: str) -> Any:
|
|||
|
||||
Callers own the connection lifecycle. The api creates one client at startup
|
||||
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.maint_notifications import MaintNotificationsConfig
|
||||
|
||||
maint_config = MaintNotificationsConfig(enabled=False)
|
||||
return AsyncRedis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
maint_notifications_config=maint_config,
|
||||
)
|
||||
return AsyncRedis.from_url(redis_url, decode_responses=True)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||
return _hasher.verify(hashed, plain)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except VerificationError, InvalidHash:
|
||||
except (VerificationError, InvalidHash):
|
||||
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."""
|
||||
try:
|
||||
return _hasher.check_needs_rehash(hashed)
|
||||
except InvalidHash, TypeError:
|
||||
except (InvalidHash, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,4 +28,4 @@ def init_sentry(service_name: str | None = None) -> None:
|
|||
traces_sample_rate=traces_sample_rate,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,117 +1,167 @@
|
|||
"""Telemetry stubs after passive-collection refactor.
|
||||
"""OpenTelemetry initialization.
|
||||
|
||||
The application no longer pushes OTLP traces, logs, or metrics. Logs are
|
||||
written to stdout/stderr by ``core/logging.py`` and collected by the
|
||||
container runtime or a sidecar (Vector in the ``observer`` profile,
|
||||
Promtail in the ``obs`` profile). Prometheus metrics stay in-process and
|
||||
are scraped from service ``/metrics`` endpoints.
|
||||
Sets up OTLP exporters for traces, logs, and metrics when
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` is set; otherwise no-op. httpx auto-instrumentation
|
||||
is wired so Ollama Cloud calls appear as spans. Service entrypoints call
|
||||
`setup_telemetry()` early and `shutdown_telemetry()` on exit.
|
||||
|
||||
``setup_telemetry()`` and ``shutdown_telemetry()`` remain as safe no-op
|
||||
entrypoints so service ``__main__`` modules do not all have to change at
|
||||
once. ``get_tracer()`` and ``get_meter()`` return lightweight no-op objects
|
||||
so any remaining instrumentation calls do not raise.
|
||||
Auth headers are taken from `OTEL_EXPORTER_OTLP_HEADERS` by the OTLP exporters
|
||||
(no code-level parsing needed).
|
||||
|
||||
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 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:
|
||||
"""Span-like object that ignores all operations."""
|
||||
def setup_telemetry(service_name: str | None = None) -> None:
|
||||
"""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:
|
||||
return None
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
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:
|
||||
return None
|
||||
class StructlogOTLPLogHandler(LoggingHandler):
|
||||
"""OTEL handler that unwraps structlog event dicts.
|
||||
|
||||
def record_exception(self, _exception: BaseException, _attributes: Any = None) -> None:
|
||||
return None
|
||||
``core/logging.py`` routes structlog through stdlib logging; the
|
||||
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:
|
||||
return self
|
||||
_STRUCTLOG_INTERNAL_KEYS = frozenset({"_logger", "_name"})
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
return None
|
||||
def _translate(self, record: logging.LogRecord) -> Any:
|
||||
# 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:
|
||||
"""Tracer-like object that returns no-op spans."""
|
||||
# Traces. Exporters read OTEL_EXPORTER_OTLP_ENDPOINT and append the
|
||||
# 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:
|
||||
return _NoopSpan()
|
||||
# Logs (stdlib logging → OTLP). ``configure_logging`` routes structlog
|
||||
# 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:
|
||||
return _NoopSpan()
|
||||
otel_log_handler = StructlogOTLPLogHandler(logger_provider=log_provider)
|
||||
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:
|
||||
"""Counter-like object that ignores add() calls."""
|
||||
try:
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
|
||||
def add(self, _amount: int | float, _attributes: Any = None) -> None:
|
||||
return None
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
except Exception as exc: # pragma: no cover - optional instrumentor
|
||||
log.warning("otel_httpx_instrument_failed", error=str(exc))
|
||||
|
||||
|
||||
class _NoopHistogram:
|
||||
"""Histogram-like object that ignores record() calls."""
|
||||
|
||||
def record(self, _amount: int | float, _attributes: Any = None) -> None:
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
log.info("otel_initialized", endpoint=endpoint)
|
||||
# Stdlib log so the OTel handler exports a verification record too.
|
||||
logging.getLogger("contract_check.telemetry").info(
|
||||
"OpenTelemetry initialized for %s at %s",
|
||||
service_name or settings.otel_service_name,
|
||||
endpoint,
|
||||
)
|
||||
|
||||
|
||||
def shutdown_telemetry() -> None:
|
||||
"""No-op telemetry shutdown retained for backward compatibility."""
|
||||
return None
|
||||
"""Flush and shut down all OTel providers if initialized."""
|
||||
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:
|
||||
"""Return a no-op tracer.
|
||||
def get_tracer(name: str | None = None) -> Any:
|
||||
"""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
|
||||
``start_span`` or ``start_as_current_span`` are silently ignored.
|
||||
"""
|
||||
return _TRACER
|
||||
return trace.get_tracer(name or "contract_check")
|
||||
|
||||
|
||||
def get_meter(_name: str | None = None) -> _NoopMeter:
|
||||
"""Return a no-op meter.
|
||||
def get_meter(name: str | None = None) -> Any:
|
||||
"""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
|
||||
calls are silently ignored.
|
||||
"""
|
||||
return _METER
|
||||
return metrics.get_meter(name or "contract_check")
|
||||
|
|
|
|||
|
|
@ -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.metrics import start_metrics_server
|
||||
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
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -24,6 +25,7 @@ async def main() -> None:
|
|||
)
|
||||
bind_context(service="worker-analyze", env=settings.env)
|
||||
init_sentry("worker-analyze")
|
||||
setup_telemetry("worker-analyze")
|
||||
|
||||
start_metrics_server(9102)
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ async def main() -> None:
|
|||
)
|
||||
finally:
|
||||
await consumer.stop()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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.metrics import start_metrics_server
|
||||
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
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -29,6 +30,7 @@ async def main() -> None:
|
|||
)
|
||||
bind_context(service="worker-billing", env=settings.env)
|
||||
init_sentry("worker-billing")
|
||||
setup_telemetry("worker-billing")
|
||||
|
||||
start_metrics_server(9105)
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ async def main() -> None:
|
|||
if provider is not None:
|
||||
await provider.aclose()
|
||||
await dispose_engine()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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.metrics import start_metrics_server
|
||||
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
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -24,6 +25,7 @@ async def main() -> None:
|
|||
)
|
||||
bind_context(service="worker-extract", env=settings.env)
|
||||
init_sentry("worker-extract")
|
||||
setup_telemetry("worker-extract")
|
||||
|
||||
start_metrics_server(9101)
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ async def main() -> None:
|
|||
)
|
||||
finally:
|
||||
await consumer.stop()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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.metrics import start_metrics_server
|
||||
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
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -24,6 +25,7 @@ async def main() -> None:
|
|||
)
|
||||
bind_context(service="worker-notify", env=settings.env)
|
||||
init_sentry("worker-notify")
|
||||
setup_telemetry("worker-notify")
|
||||
|
||||
start_metrics_server(9103)
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ async def main() -> None:
|
|||
)
|
||||
finally:
|
||||
await consumer.stop()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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.metrics import start_metrics_server
|
||||
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
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -24,6 +25,7 @@ async def main() -> None:
|
|||
)
|
||||
bind_context(service="worker-prescreen", env=settings.env)
|
||||
init_sentry("worker-prescreen")
|
||||
setup_telemetry("worker-prescreen")
|
||||
|
||||
start_metrics_server(9104)
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ async def main() -> None:
|
|||
)
|
||||
finally:
|
||||
await consumer.stop()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ from src.contract_check.worker_prescreen.extractor import (
|
|||
from src.contract_check.worker_prescreen.extractor_heuristic import (
|
||||
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 (
|
||||
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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
UV_LINK_MODE=copy \
|
||||
|
|
@ -13,13 +13,13 @@ WORKDIR /app
|
|||
COPY pyproject.toml uv.lock ./
|
||||
COPY README.md ./
|
||||
|
||||
# Install only the analyze group (core + db/mq/s3/obs). No tesseract/pymupdf
|
||||
# — this image is the leanest LLM/I/O worker.
|
||||
# Install only the analyze group (core + db/mq/s3/obs + otel-httpx). No
|
||||
# tesseract/pymupdf — this image is the leanest LLM/I/O worker.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-default-groups --group analyze --no-install-project
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: runtime with tesseract-ocr + language packs ────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
@ -33,7 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
tesseract-ocr-rus \
|
||||
tesseract-ocr-eng \
|
||||
fonts-dejavu-core \
|
||||
libmagic1t64 \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── 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 \
|
||||
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
|
||||
|
||||
# ─── Stage 2: lean runtime ─────────────────────────────────────────────────
|
||||
FROM python:3.14-slim-trixie
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
by the session-scoped `infra` fixture and torn down after the session. This
|
||||
keeps integration tests from racing against the development service workers
|
||||
that also consume RabbitMQ queues.
|
||||
|
||||
To run against an already-running external infra instead, set
|
||||
``INTEGRATION_TEST_USE_EXTERNAL_INFRA=1`` before invoking pytest.
|
||||
Run `docker compose up -d` before executing integration tests. Migrations run
|
||||
once per session; a service token is seeded so adapter-style endpoints can
|
||||
authenticate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -38,113 +32,9 @@ pytestmark = pytest.mark.integration
|
|||
|
||||
_BOT_SERVICE_TOKEN = "it-test-bot-token"
|
||||
|
||||
# Ports must match docker-compose.test.yml.
|
||||
_TEST_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:25432/contract_check"
|
||||
_TEST_AMQP_URL = "amqp://contract_check:contract_check@localhost:6672//"
|
||||
_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)
|
||||
_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check"
|
||||
_AMQP_URL = "amqp://contract_check:contract_check@localhost:5672//"
|
||||
_S3_URL = "http://localhost:9000"
|
||||
|
||||
|
||||
def _set_env() -> None:
|
||||
|
|
@ -154,8 +44,8 @@ def _set_env() -> None:
|
|||
"S3_ENDPOINT_URL": _S3_URL,
|
||||
"S3_ACCESS_KEY": "contract_check",
|
||||
"S3_SECRET_KEY": "contract_check",
|
||||
"S3_BUCKET": "contract-check-docs-test",
|
||||
"REDIS_URL": "redis://localhost:27379/0",
|
||||
"S3_BUCKET": "contract-check-docs",
|
||||
"REDIS_URL": "redis://localhost:17379/0",
|
||||
"OLLAMA_HOST": "http://localhost",
|
||||
"OLLAMA_API_KEY": "test",
|
||||
"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.
|
||||
"SSE_POLL_INTERVAL_SECONDS": "0.25",
|
||||
"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():
|
||||
os.environ[k] = v
|
||||
|
|
@ -182,19 +67,13 @@ def infra() -> Iterator[dict[str, str]]:
|
|||
_set_env()
|
||||
get_settings.cache_clear()
|
||||
|
||||
_start_test_infra()
|
||||
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"alembic",
|
||||
"-c",
|
||||
str(_repo_root() / "alembic.ini"),
|
||||
"upgrade",
|
||||
"head",
|
||||
],
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "alembic", "-c", str(repo_root / "alembic.ini"), "upgrade", "head"],
|
||||
cwd=str(repo_root),
|
||||
env={**os.environ},
|
||||
check=True,
|
||||
capture_output=False,
|
||||
)
|
||||
|
||||
factory = create_session_factory()
|
||||
|
|
@ -222,12 +101,10 @@ def infra() -> Iterator[dict[str, str]]:
|
|||
"s3_endpoint_url": _S3_URL,
|
||||
"s3_access_key": "contract_check",
|
||||
"s3_secret_key": "contract_check",
|
||||
"s3_bucket": "contract-check-docs-test",
|
||||
"token": _BOT_SERVICE_TOKEN,
|
||||
}
|
||||
|
||||
get_settings.cache_clear()
|
||||
_stop_test_infra()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
|
|||
endpoint_url=infra["s3_endpoint_url"],
|
||||
access_key=infra["s3_access_key"],
|
||||
secret_key=infra["s3_secret_key"],
|
||||
bucket=infra["s3_bucket"],
|
||||
bucket="contract-check-docs",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
|
|||
endpoint_url=infra["s3_endpoint_url"],
|
||||
access_key=infra["s3_access_key"],
|
||||
secret_key=infra["s3_secret_key"],
|
||||
bucket=infra["s3_bucket"],
|
||||
bucket="contract-check-docs",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
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.core.config import get_settings
|
||||
|
||||
# Reuse the infra Redis (set by conftest `_set_env`, test-stack port);
|
||||
# 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", "", ""))
|
||||
isolated_redis_url = "redis://localhost:17379/15"
|
||||
r = redis.from_url(isolated_redis_url)
|
||||
await r.flushdb()
|
||||
await r.aclose()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def storage(infra: dict[str, str]) -> MinioStorage:
|
|||
endpoint_url=infra["s3_endpoint_url"],
|
||||
access_key=infra["s3_access_key"],
|
||||
secret_key=infra["s3_secret_key"],
|
||||
bucket=infra["s3_bucket"],
|
||||
bucket="contract-check-docs",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
|
|||
endpoint_url=infra["s3_endpoint_url"],
|
||||
access_key=infra["s3_access_key"],
|
||||
secret_key=infra["s3_secret_key"],
|
||||
bucket=infra["s3_bucket"],
|
||||
bucket="contract-check-docs",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
|
|||
endpoint_url=infra["s3_endpoint_url"],
|
||||
access_key=infra["s3_access_key"],
|
||||
secret_key=infra["s3_secret_key"],
|
||||
bucket=infra["s3_bucket"],
|
||||
bucket="contract-check-docs",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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") == "Обрабатываю…"
|
||||
|
|
@ -145,37 +145,3 @@ async def test_event_hooks_silent_without_debug(monkeypatch: pytest.MonkeyPatch)
|
|||
await client.get("https://api.test/v1/things")
|
||||
|
||||
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>"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
setup/shutdown are safe no-ops and that structured logging still reaches the
|
||||
configured root handler.
|
||||
Covers the structlog-aware OTEL log handler that unwraps event dicts so
|
||||
OpenObserve receives structured attributes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,7 +11,6 @@ import logging
|
|||
import pytest
|
||||
|
||||
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)
|
||||
|
|
@ -24,64 +22,82 @@ def _reset_logging():
|
|||
root.handlers.clear()
|
||||
|
||||
|
||||
def test_setup_telemetry_is_safe_noop() -> None:
|
||||
"""setup_telemetry() runs without error and does not add an OTLP handler."""
|
||||
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")
|
||||
def test_structlog_otlp_handler_unwraps_event_dict(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A structlog-wrapped record is converted to body + attributes."""
|
||||
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
|
||||
# be picked up by telemetry code. Refresh the cached settings instance.
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
get_settings(), "otel_exporter_otlp_endpoint", "http://localhost:9999/v1/logs"
|
||||
)
|
||||
|
||||
root = logging.getLogger()
|
||||
before = list(root.handlers)
|
||||
# setup_telemetry defines the handler class when OTEL is imported.
|
||||
setup_telemetry("test")
|
||||
root = logging.getLogger()
|
||||
try:
|
||||
after = list(root.handlers)
|
||||
assert after == before
|
||||
assert not any(type(h).__name__.startswith(("OTLP", "StructlogOTLP")) for h in after)
|
||||
otel_handler = next(
|
||||
h for h in root.handlers if type(h).__name__ == "StructlogOTLPLogHandler"
|
||||
)
|
||||
|
||||
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:
|
||||
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:
|
||||
"""Tracer/meter accessors return objects that do not require OTel SDK."""
|
||||
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."""
|
||||
def test_structlog_logs_reach_root_handlers() -> None:
|
||||
"""After configure_logging, structlog events propagate to stdlib handlers."""
|
||||
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.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"]
|
||||
|
|
|
|||
|
|
@ -74,13 +74,8 @@ class FakeProvider(PaymentProvider):
|
|||
async def factory():
|
||||
import os
|
||||
|
||||
# Integration conftest (_set_env) already points DATABASE_URL at the test
|
||||
# infra (:25432); only fall back to the dev port when it is unset, and
|
||||
# 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["DATABASE_URL"] = (
|
||||
"postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check"
|
||||
)
|
||||
os.environ["PLANS_ENABLED"] = "true"
|
||||
os.environ["YOOKASSA_ENABLED"] = "true"
|
||||
|
|
@ -90,10 +85,7 @@ async def factory():
|
|||
get_settings.cache_clear()
|
||||
f = create_session_factory()
|
||||
yield f
|
||||
if prev_url is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = prev_url
|
||||
os.environ.pop("PLANS_ENABLED", None)
|
||||
os.environ.pop("YOOKASSA_ENABLED", None)
|
||||
os.environ.pop("YOOKASSA_SHOP_ID", None)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue