mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
fix speed up 01
This commit is contained in:
parent
253e7d5839
commit
cf3b56deeb
10 changed files with 426 additions and 4 deletions
9
.codex/AGENT_INSTRUCTIONS.md
Normal file
9
.codex/AGENT_INSTRUCTIONS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
## Repository Operating Guide
|
||||||
|
|
||||||
|
- Make the smallest correct change and prefer minimal diffs.
|
||||||
|
- Read `.codex/PROJECT_CONTEXT.md` before expanding into new areas.
|
||||||
|
- Preserve existing backend/frontend patterns and naming.
|
||||||
|
- Avoid whole-repo scans unless imports, failing tests, or uncertainty require it.
|
||||||
|
- Run the smallest useful validation first.
|
||||||
|
- Run tests inside containers when tests are needed.
|
||||||
|
- Do not change unrelated files or refactor opportunistically.
|
||||||
33
.codex/PROJECT_CONTEXT.md
Normal file
33
.codex/PROJECT_CONTEXT.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
## Repository Context Map
|
||||||
|
|
||||||
|
### Entry Points
|
||||||
|
- `app/main.py`: main backend API (`/api/public`, `/api/admin`).
|
||||||
|
- `app/chat_main.py`: dedicated chat API service.
|
||||||
|
- `app/email_main.py`: email service.
|
||||||
|
- `docker-compose.yml`: local service topology.
|
||||||
|
|
||||||
|
### Main Backend Areas
|
||||||
|
- `app/api/public/`: public/client cabinet endpoints.
|
||||||
|
- `app/api/admin/`: admin and lawyer endpoints.
|
||||||
|
- `app/api/admin/requests_modules/kanban.py`: kanban aggregation and filters.
|
||||||
|
- `app/api/admin/crud_modules/`: generic CRUD/query layer.
|
||||||
|
- `app/services/`: shared domain services, including chat serialization/security.
|
||||||
|
- `app/models/`: SQLAlchemy models.
|
||||||
|
- `app/core/`: config, middleware, security hardening.
|
||||||
|
|
||||||
|
### Frontend Areas
|
||||||
|
- `app/web/admin/`: admin/lawyer UI source modules.
|
||||||
|
- `app/web/client.jsx`: client cabinet entry.
|
||||||
|
- `app/web/admin.js`, `app/web/client.js`: built bundles.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_http_hardening.py`: middleware/security headers.
|
||||||
|
- `tests/test_public_cabinet.py`: client cabinet flows.
|
||||||
|
- `tests/admin/test_lawyer_chat.py`: admin/lawyer chat flows.
|
||||||
|
- `tests/test_migrations.py`: migration coverage.
|
||||||
|
|
||||||
|
### High-Risk Zones
|
||||||
|
- Request workspace loading: admin `app/web/admin/hooks/useRequestWorkspace.js`, client `app/web/client.jsx`.
|
||||||
|
- Kanban performance: `app/api/admin/requests_modules/kanban.py`.
|
||||||
|
- Generic query endpoints used by request modal: `app/api/admin/crud_modules/service.py`, `app/api/admin/invoices.py`.
|
||||||
|
- Chat serialization and live updates: `app/services/chat_secure_service.py`, public/admin chat routers.
|
||||||
37
alembic/versions/0034_add_request_assigned_lawyer_index.py
Normal file
37
alembic/versions/0034_add_request_assigned_lawyer_index.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""add index for requests.assigned_lawyer_id
|
||||||
|
|
||||||
|
Revision ID: 0034_request_assigned_lawyer_idx
|
||||||
|
Revises: 0033_message_receipts
|
||||||
|
Create Date: 2026-03-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0034_request_assigned_lawyer_idx"
|
||||||
|
down_revision = "0033_message_receipts"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_index(inspector: sa.Inspector, table: str, index_name: str) -> bool:
|
||||||
|
return any(str(idx.get("name")) == index_name for idx in inspector.get_indexes(table))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if not _has_index(inspector, "requests", "ix_requests_assigned_lawyer_id"):
|
||||||
|
op.create_index("ix_requests_assigned_lawyer_id", "requests", ["assigned_lawyer_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if _has_index(inspector, "requests", "ix_requests_assigned_lawyer_id"):
|
||||||
|
op.drop_index("ix_requests_assigned_lawyer_id", table_name="requests")
|
||||||
|
|
@ -58,6 +58,23 @@ _FRAMEABLE_PATH_PATTERNS = (
|
||||||
re.compile(r"^/api/admin/invoices/[^/]+/pdf$"),
|
re.compile(r"^/api/admin/invoices/[^/]+/pdf$"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PERF_PATH_PATTERNS = (
|
||||||
|
("admin_kanban", re.compile(r"^/api/admin/requests/kanban$")),
|
||||||
|
("admin_request_detail", re.compile(r"^/api/admin/crud/requests/[^/]+$")),
|
||||||
|
("admin_chat_messages", re.compile(r"^/api/admin/chat/requests/[^/]+/messages$")),
|
||||||
|
("admin_chat_live", re.compile(r"^/api/admin/chat/requests/[^/]+/live$")),
|
||||||
|
("admin_request_status_route", re.compile(r"^/api/admin/requests/[^/]+/status-route$")),
|
||||||
|
("admin_request_attachments_query", re.compile(r"^/api/admin/crud/attachments/query$")),
|
||||||
|
("admin_request_invoices_query", re.compile(r"^/api/admin/invoices/query$")),
|
||||||
|
("public_request_detail", re.compile(r"^/api/public/requests/[^/]+$")),
|
||||||
|
("public_chat_messages", re.compile(r"^/api/public/chat/requests/[^/]+/messages$")),
|
||||||
|
("public_chat_live", re.compile(r"^/api/public/chat/requests/[^/]+/live$")),
|
||||||
|
("public_request_attachments", re.compile(r"^/api/public/requests/[^/]+/attachments$")),
|
||||||
|
("public_request_invoices", re.compile(r"^/api/public/requests/[^/]+/invoices$")),
|
||||||
|
("public_request_status_route", re.compile(r"^/api/public/requests/[^/]+/status-route$")),
|
||||||
|
("public_request_service_requests", re.compile(r"^/api/public/requests/[^/]+/service-requests$")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _request_id_from_header(raw: str | None) -> str:
|
def _request_id_from_header(raw: str | None) -> str:
|
||||||
value = str(raw or "").strip()
|
value = str(raw or "").strip()
|
||||||
|
|
@ -76,6 +93,17 @@ def _response_security_headers(request: Request) -> dict[str, str]:
|
||||||
return SECURITY_HEADERS
|
return SECURITY_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
def _performance_label(request: Request) -> str | None:
|
||||||
|
method = str(request.method or "").upper()
|
||||||
|
if method not in {"GET", "POST"}:
|
||||||
|
return None
|
||||||
|
path = str(request.url.path or "")
|
||||||
|
for label, pattern in _PERF_PATH_PATTERNS:
|
||||||
|
if pattern.search(path):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def install_http_hardening(app: FastAPI) -> None:
|
def install_http_hardening(app: FastAPI) -> None:
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def _http_hardening_middleware(request: Request, call_next):
|
async def _http_hardening_middleware(request: Request, call_next):
|
||||||
|
|
@ -95,12 +123,18 @@ def install_http_hardening(app: FastAPI) -> None:
|
||||||
response.headers[REQUEST_ID_HEADER] = request_id
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
|
|
||||||
duration_ms = (perf_counter() - started_at) * 1000.0
|
duration_ms = (perf_counter() - started_at) * 1000.0
|
||||||
|
perf_label = _performance_label(request)
|
||||||
|
if perf_label:
|
||||||
|
response.headers["Server-Timing"] = f'app;desc="{perf_label}";dur={duration_ms:.2f}'
|
||||||
|
response.headers["X-Perf-Label"] = perf_label
|
||||||
|
response.headers["X-Perf-Duration-Ms"] = f"{duration_ms:.2f}"
|
||||||
_LOG.info(
|
_LOG.info(
|
||||||
"%s %s status=%s duration_ms=%.2f request_id=%s",
|
"%s %s status=%s duration_ms=%.2f request_id=%s perf_label=%s",
|
||||||
request.method,
|
request.method,
|
||||||
request.url.path,
|
request.url.path,
|
||||||
response.status_code,
|
response.status_code,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
request_id,
|
request_id,
|
||||||
|
perf_label or "-",
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class Request(Base, UUIDMixin, TimestampMixin):
|
||||||
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
extra_fields: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
assigned_lawyer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
effective_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||||
request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
request_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
invoice_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ curl -fsS http://localhost:8081/chat-health
|
||||||
./scripts/ops/check_chat_health.sh
|
./scripts/ops/check_chat_health.sh
|
||||||
echo $? # 0=OK, >0=ALERT
|
echo $? # 0=OK, >0=ALERT
|
||||||
```
|
```
|
||||||
|
10. Снятие baseline по производительности admin workspace:
|
||||||
|
```bash
|
||||||
|
./scripts/ops/perf_baseline.sh http://localhost:8081
|
||||||
|
```
|
||||||
|
Отчет сохраняется в `reports/perf/perf-baseline-<timestamp>.md`. Скрипт логинится под `admin@example.com / admin123`, берет первую заявку из канбана и замеряет `kanban`, `request detail`, `chat messages/live`, `status-route`, `attachments`, `invoices`.
|
||||||
|
|
||||||
## Матрица проверок по задачам
|
## Матрица проверок по задачам
|
||||||
| ID | Что проверяем | Где тесты | Как запускать |
|
| ID | Что проверяем | Где тесты | Как запускать |
|
||||||
|
|
|
||||||
55
context/19_performance_tracking_2026-03-16.md
Normal file
55
context/19_performance_tracking_2026-03-16.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Performance Tracking
|
||||||
|
|
||||||
|
Дата старта: 2026-03-16
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Снизить воспринимаемую задержку при открытии канбана, карточки заявки и чата. Базовая инфраструктура: `4 vCPU / 8 GB RAM / SSD 150 GB`.
|
||||||
|
|
||||||
|
## Текущая гипотеза
|
||||||
|
|
||||||
|
- Основная задержка создается не железом, а лишними round-trip между фронтом и backend/chat-service.
|
||||||
|
- Карточка заявки загружается несколькими параллельными запросами и при live-обновлениях перезагружается целиком.
|
||||||
|
- Канбан собирается через загрузку большого массива заявок в Python и дальнейшую агрегацию в памяти.
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
| ID | Задача | Статус | Приоритет | Зависимости |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| PERF-01 | Зафиксировать baseline по ключевым endpoint и сценариям | in_progress | P0 | — |
|
||||||
|
| PERF-02 | Добавить индекс на `requests.assigned_lawyer_id` | completed | P0 | — |
|
||||||
|
| PERF-03 | Убрать full reload карточки заявки при live-обновлениях | planned | P0 | PERF-01 |
|
||||||
|
| PERF-04 | Собрать единый endpoint карточки заявки | planned | P0 | PERF-01 |
|
||||||
|
| PERF-05 | Выделить узкие request-scoped endpoints для вложений и счетов | planned | P0 | PERF-04 |
|
||||||
|
| PERF-06 | Переписать kanban на SQL-first фильтрацию/limit | planned | P0 | PERF-01, PERF-02 |
|
||||||
|
| PERF-07 | Ограничить initial chat payload и добавить догрузку истории | planned | P1 | PERF-03, PERF-04 |
|
||||||
|
| PERF-08 | Добавить нужные вспомогательные индексы и повторный profiling | planned | P1 | PERF-01 |
|
||||||
|
|
||||||
|
## PERF-01
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Добавить серверные замеры для проблемных endpoint без изменения контрактов.
|
||||||
|
- Помечать в логах и headers целевые сценарии:
|
||||||
|
- kanban
|
||||||
|
- request workspace
|
||||||
|
- chat messages
|
||||||
|
- chat live
|
||||||
|
- status route
|
||||||
|
- attachments
|
||||||
|
- invoices
|
||||||
|
|
||||||
|
### Progress
|
||||||
|
|
||||||
|
- 2026-03-16: создан tracking-файл.
|
||||||
|
- 2026-03-16: в работе точечная инструментализация через существующий HTTP middleware.
|
||||||
|
- 2026-03-16: добавлены `Server-Timing`, `X-Perf-Label`, `X-Perf-Duration-Ms` для ключевых endpoint.
|
||||||
|
- 2026-03-16: контейнерный тест `python -m unittest tests.test_http_hardening -v` пройден.
|
||||||
|
- 2026-03-16: добавлен ops-скрипт `scripts/ops/perf_baseline.sh` для repeatable baseline по admin workspace.
|
||||||
|
- 2026-03-16: baseline еще не снят, потому что локальный контур на `localhost:8081` не поднят.
|
||||||
|
- 2026-03-16: выполнен `PERF-02` - добавлен индекс `ix_requests_assigned_lawyer_id`, миграционный тест пройден.
|
||||||
|
|
||||||
|
## Дальше
|
||||||
|
|
||||||
|
1. Поднять локальный контур и выполнить `./scripts/ops/perf_baseline.sh http://localhost:8081`.
|
||||||
|
2. После снятия baseline перейти к `PERF-06` и убирать `base_query.all()` из kanban.
|
||||||
216
scripts/ops/perf_baseline.sh
Executable file
216
scripts/ops/perf_baseline.sh
Executable file
|
|
@ -0,0 +1,216 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:8081}"
|
||||||
|
REPORT_DIR="${REPORT_DIR:-reports/perf}"
|
||||||
|
ITERATIONS="${PERF_ITERATIONS:-5}"
|
||||||
|
ADMIN_EMAIL="${PERF_ADMIN_EMAIL:-admin@example.com}"
|
||||||
|
ADMIN_PASSWORD="${PERF_ADMIN_PASSWORD:-admin123}"
|
||||||
|
KANBAN_LIMIT="${PERF_KANBAN_LIMIT:-400}"
|
||||||
|
TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
|
||||||
|
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
|
||||||
|
REPORT_FILE="${REPORT_DIR}/perf-baseline-${TS_FILE}.md"
|
||||||
|
|
||||||
|
mkdir -p "$REPORT_DIR"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
echo "missing command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd python3
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
print(json.dumps(sys.argv[1]))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGIN_BODY="$(printf '{"email":%s,"password":%s}' "$(json_escape "$ADMIN_EMAIL")" "$(json_escape "$ADMIN_PASSWORD")")"
|
||||||
|
LOGIN_RESPONSE_FILE="$TMP_DIR/login.json"
|
||||||
|
|
||||||
|
curl -fsS \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
-d "$LOGIN_BODY" \
|
||||||
|
"$BASE_URL/api/admin/auth/login" >"$LOGIN_RESPONSE_FILE"
|
||||||
|
|
||||||
|
AUTH_TOKEN="$(python3 - "$LOGIN_RESPONSE_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
token = str(data.get("access_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise SystemExit("login did not return access_token")
|
||||||
|
print(token)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
KANBAN_BODY_FILE="$TMP_DIR/kanban.json"
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
"$BASE_URL/api/admin/requests/kanban?limit=${KANBAN_LIMIT}&sort_mode=created_newest" >"$KANBAN_BODY_FILE"
|
||||||
|
|
||||||
|
REQUEST_ID="$(python3 - "$KANBAN_BODY_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
rows = data.get("rows") or []
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit("kanban returned no rows; seed manual data first")
|
||||||
|
request_id = str((rows[0] or {}).get("id") or "").strip()
|
||||||
|
if not request_id:
|
||||||
|
raise SystemExit("kanban first row has no id")
|
||||||
|
print(request_id)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
ATTACHMENTS_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"created_at","dir":"asc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
|
||||||
|
INVOICES_BODY="$(printf '{"filters":[{"field":"request_id","op":"=","value":%s}],"sort":[{"field":"issued_at","dir":"desc"}],"page":{"limit":500,"offset":0}}' "$(json_escape "$REQUEST_ID")")"
|
||||||
|
|
||||||
|
measure_endpoint() {
|
||||||
|
local name="$1"
|
||||||
|
local method="$2"
|
||||||
|
local path="$3"
|
||||||
|
local body="${4:-}"
|
||||||
|
|
||||||
|
local headers_file body_file curl_meta status_code total_ms perf_label perf_duration
|
||||||
|
for run in $(seq 1 "$ITERATIONS"); do
|
||||||
|
headers_file="$TMP_DIR/${name}-${run}.headers"
|
||||||
|
body_file="$TMP_DIR/${name}-${run}.body"
|
||||||
|
if [[ "$method" == "POST" ]]; then
|
||||||
|
curl_meta="$(curl -sS \
|
||||||
|
-D "$headers_file" \
|
||||||
|
-o "$body_file" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
-d "$body" \
|
||||||
|
-w '%{http_code} %{time_total}' \
|
||||||
|
"$BASE_URL$path")"
|
||||||
|
else
|
||||||
|
curl_meta="$(curl -sS \
|
||||||
|
-D "$headers_file" \
|
||||||
|
-o "$body_file" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-w '%{http_code} %{time_total}' \
|
||||||
|
"$BASE_URL$path")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_code="$(echo "$curl_meta" | awk '{print $1}')"
|
||||||
|
total_ms="$(echo "$curl_meta" | awk '{printf "%.2f", $2 * 1000}')"
|
||||||
|
|
||||||
|
if [[ "$status_code" != "200" ]]; then
|
||||||
|
echo "endpoint ${name} failed: HTTP ${status_code}" >&2
|
||||||
|
cat "$body_file" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$headers_file" "$name" "$run" "$total_ms" >>"$TMP_DIR/raw.tsv" <<'PY'
|
||||||
|
import sys
|
||||||
|
|
||||||
|
headers_path, name, run, total_ms = sys.argv[1:5]
|
||||||
|
headers = {}
|
||||||
|
with open(headers_path, "r", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
headers[key.strip().lower()] = value.strip()
|
||||||
|
print("\t".join([
|
||||||
|
name,
|
||||||
|
run,
|
||||||
|
total_ms,
|
||||||
|
headers.get("x-perf-label", ""),
|
||||||
|
headers.get("x-perf-duration-ms", ""),
|
||||||
|
]))
|
||||||
|
PY
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
: >"$TMP_DIR/raw.tsv"
|
||||||
|
|
||||||
|
measure_endpoint "kanban" "GET" "/api/admin/requests/kanban?limit=${KANBAN_LIMIT}&sort_mode=created_newest"
|
||||||
|
measure_endpoint "request_detail" "GET" "/api/admin/crud/requests/${REQUEST_ID}"
|
||||||
|
measure_endpoint "chat_messages" "GET" "/api/admin/chat/requests/${REQUEST_ID}/messages"
|
||||||
|
measure_endpoint "chat_live" "GET" "/api/admin/chat/requests/${REQUEST_ID}/live"
|
||||||
|
measure_endpoint "status_route" "GET" "/api/admin/requests/${REQUEST_ID}/status-route"
|
||||||
|
measure_endpoint "attachments_query" "POST" "/api/admin/crud/attachments/query" "$ATTACHMENTS_BODY"
|
||||||
|
measure_endpoint "invoices_query" "POST" "/api/admin/invoices/query" "$INVOICES_BODY"
|
||||||
|
|
||||||
|
python3 - "$TMP_DIR/raw.tsv" "$REPORT_FILE" "$TS_HUMAN" "$BASE_URL" "$REQUEST_ID" "$ITERATIONS" <<'PY'
|
||||||
|
import csv
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
raw_path, report_path, ts_human, base_url, request_id, iterations = sys.argv[1:7]
|
||||||
|
|
||||||
|
rows = defaultdict(list)
|
||||||
|
with open(raw_path, "r", encoding="utf-8") as fh:
|
||||||
|
reader = csv.reader(fh, delimiter="\t")
|
||||||
|
for name, run, total_ms, perf_label, perf_duration in reader:
|
||||||
|
rows[name].append(
|
||||||
|
{
|
||||||
|
"run": int(run),
|
||||||
|
"total_ms": float(total_ms or 0),
|
||||||
|
"perf_label": perf_label or "-",
|
||||||
|
"perf_duration_ms": float(perf_duration or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def percentile(sorted_values, ratio):
|
||||||
|
if not sorted_values:
|
||||||
|
return 0.0
|
||||||
|
if len(sorted_values) == 1:
|
||||||
|
return sorted_values[0]
|
||||||
|
index = round((len(sorted_values) - 1) * ratio)
|
||||||
|
return sorted_values[index]
|
||||||
|
|
||||||
|
with open(report_path, "w", encoding="utf-8") as out:
|
||||||
|
out.write("# Perf Baseline Report\n\n")
|
||||||
|
out.write(f"- Timestamp: `{ts_human}`\n")
|
||||||
|
out.write(f"- Base URL: `{base_url}`\n")
|
||||||
|
out.write(f"- Request ID sample: `{request_id}`\n")
|
||||||
|
out.write(f"- Iterations per endpoint: `{iterations}`\n\n")
|
||||||
|
out.write("| Endpoint | Perf Label | Avg Total ms | P95 Total ms | Avg Server ms |\n")
|
||||||
|
out.write("|---|---|---:|---:|---:|\n")
|
||||||
|
for name in [
|
||||||
|
"kanban",
|
||||||
|
"request_detail",
|
||||||
|
"chat_messages",
|
||||||
|
"chat_live",
|
||||||
|
"status_route",
|
||||||
|
"attachments_query",
|
||||||
|
"invoices_query",
|
||||||
|
]:
|
||||||
|
items = rows.get(name, [])
|
||||||
|
totals = sorted(item["total_ms"] for item in items)
|
||||||
|
servers = [item["perf_duration_ms"] for item in items if item["perf_duration_ms"] > 0]
|
||||||
|
avg_total = statistics.mean(totals) if totals else 0.0
|
||||||
|
p95_total = percentile(totals, 0.95)
|
||||||
|
avg_server = statistics.mean(servers) if servers else 0.0
|
||||||
|
label = items[0]["perf_label"] if items else "-"
|
||||||
|
out.write(f"| {name} | `{label}` | {avg_total:.2f} | {p95_total:.2f} | {avg_server:.2f} |\n")
|
||||||
|
out.write("\n## Raw Runs\n\n")
|
||||||
|
out.write("| Endpoint | Run | Total ms | Server ms |\n")
|
||||||
|
out.write("|---|---:|---:|---:|\n")
|
||||||
|
for name, items in rows.items():
|
||||||
|
for item in sorted(items, key=lambda value: value["run"]):
|
||||||
|
out.write(f"| {name} | {item['run']} | {item['total_ms']:.2f} | {item['perf_duration_ms']:.2f} |\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "report: $REPORT_FILE"
|
||||||
|
|
@ -11,7 +11,7 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
os.environ.setdefault("S3_BUCKET", "test")
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.core.http_hardening import _response_security_headers
|
from app.core.http_hardening import _performance_label, _response_security_headers
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,6 +77,35 @@ class HttpHardeningTests(unittest.TestCase):
|
||||||
self.assertEqual(headers.get("X-Frame-Options"), "SAMEORIGIN")
|
self.assertEqual(headers.get("X-Frame-Options"), "SAMEORIGIN")
|
||||||
self.assertIn("frame-ancestors 'self'", str(headers.get("Content-Security-Policy")))
|
self.assertIn("frame-ancestors 'self'", str(headers.get("Content-Security-Policy")))
|
||||||
|
|
||||||
|
def test_target_perf_endpoint_has_observability_headers(self):
|
||||||
|
response = self.client.get("/api/admin/requests/kanban")
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertEqual(response.headers.get("x-perf-label"), "admin_kanban")
|
||||||
|
self.assertTrue(bool(response.headers.get("x-perf-duration-ms")))
|
||||||
|
self.assertIn('desc="admin_kanban"', str(response.headers.get("server-timing")))
|
||||||
|
|
||||||
|
def test_non_target_endpoint_has_no_perf_headers(self):
|
||||||
|
response = self.client.get("/health")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.headers.get("x-perf-label"))
|
||||||
|
self.assertIsNone(response.headers.get("x-perf-duration-ms"))
|
||||||
|
self.assertIsNone(response.headers.get("server-timing"))
|
||||||
|
|
||||||
|
def test_performance_label_maps_client_workspace_endpoints(self):
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"scheme": "http",
|
||||||
|
"path": "/api/public/requests/TRK-1/status-route",
|
||||||
|
"raw_path": b"/api/public/requests/TRK-1/status-route",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
}
|
||||||
|
self.assertEqual(_performance_label(Request(scope)), "public_request_status_route")
|
||||||
|
|
||||||
def test_non_file_paths_keep_deny_framing(self):
|
def test_non_file_paths_keep_deny_framing(self):
|
||||||
scope = {
|
scope = {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0033_message_receipts")
|
self.assertEqual(version, "0034_request_assigned_lawyer_idx")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -205,6 +205,10 @@ class MigrationTests(unittest.TestCase):
|
||||||
self.assertIn("paid_at", columns)
|
self.assertIn("paid_at", columns)
|
||||||
self.assertIn("paid_by_admin_id", columns)
|
self.assertIn("paid_by_admin_id", columns)
|
||||||
|
|
||||||
|
def test_requests_contains_assigned_lawyer_index(self):
|
||||||
|
indexes = {index["name"] for index in self.inspector.get_indexes("requests")}
|
||||||
|
self.assertIn("ix_requests_assigned_lawyer_id", indexes)
|
||||||
|
|
||||||
def test_data_retention_policies_contains_core_columns(self):
|
def test_data_retention_policies_contains_core_columns(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
|
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
|
||||||
self.assertIn("id", columns)
|
self.assertIn("id", columns)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue