Add request metrics database

This commit is contained in:
“Naeel”
2026-08-29 13:30:57 +03:00
parent eae62e76f8
commit 773b088bfa
4 changed files with 175 additions and 10 deletions
+33
View File
@@ -560,3 +560,36 @@ SSH-ключ с локальной машины передан на ВМ 213 в
`usage`; авторизованный POST `/recipe/` HTTP 200 с непустыми `text` и `usage`; `usage`; авторизованный POST `/recipe/` HTTP 200 с непустыми `text` и `usage`;
redirect отсутствует. `gemini-tunnel.service`, `recipe.service` и redirect отсутствует. `gemini-tunnel.service`, `recipe.service` и
`elmer.service` имеют статус active. `elmer.service` не перезапускался. `elmer.service` имеют статус active. `elmer.service` не перезапускался.
## Проверка хранения изображений
В задеплоенном `recipe_service/app.py` изображение читается через
`image.read()` в память и передаётся proxy через `requests.post(files=...)`.
Операций записи изображения на диск в коде нет; постоянное хранилище для
изображений не используется. `recipe.service` active.
В `/tmp` ВМ 213 обнаружены файлы от предыдущих ручных диагностических
запросов, включая `lekar1.png` и JSON-ответы. Они не создаются рабочим
pipeline автоматически и требуют отдельного разрешения на удаление. Это
отдельный остаток тестовых команд, а не постоянное хранилище приложения.
## SQLite-статистика запросов
По команде пользователя добавлена SQLite-база статистики на ВМ 213:
`/var/lib/recipe/metrics.sqlite3`. Хранятся request ID, время, полный IP,
User-Agent, method/path, MIME и размер изображения, длина prompt, HTTP-статус,
длительность, размер ответа, Gemini usage и безопасное описание ошибки.
Изображение, prompt, распознанный текст, Bearer-токен и Gemini key в базу не
записываются.
Первый запуск после добавления статистики выявил подтверждённую ошибку
`sqlite3.OperationalError: attempt to write a readonly database`: файл базы
был `root:root` с правами `644`, а unit использовал `ProtectSystem=strict`.
Файл переведён во владение `recipe:recipe` с правами `0660`, в unit добавлены
`StateDirectory=recipe` и `StateDirectoryMode=0770`.
После исправления `recipe.service` active. Таблица `requests` создана
автоматически. Проверка записала 401 и успешный OCR: последняя строка содержит
IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину prompt 88 и
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
читает изображение в память и не пишет его на диск.
+78 -10
View File
@@ -4,12 +4,19 @@ import os
import requests import requests
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
try:
from recipe_service.metrics import initialize, record, request_context, usage_json
except ModuleNotFoundError:
from metrics import initialize, record, request_context, usage_json
app = Flask(__name__) app = Flask(__name__)
MAX_IMAGE_BYTES = 10 * 1024 * 1024 MAX_IMAGE_BYTES = 10 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
PROXY_URL = "http://127.0.0.1:8768/gemini" PROXY_URL = "http://127.0.0.1:8768/gemini"
initialize()
def settings() -> dict: def settings() -> dict:
try: try:
@@ -35,17 +42,58 @@ def health():
@app.post("/recipe") @app.post("/recipe")
@app.post("/recipe/") @app.post("/recipe/")
def recipe(): def recipe():
request_id, started_at, started_monotonic = request_context()
image_mime = None
image_bytes = None
prompt_chars = None
status_code = 500
response_bytes = None
usage = {}
error = None
if not authorized(): if not authorized():
return jsonify(error="unauthorized"), 401 status_code = 401
error = "unauthorized"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
status_code=status_code, duration_ms=int((__import__('time').monotonic() - started_monotonic) * 1000),
response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
return response, status_code
image = request.files.get("image") image = request.files.get("image")
prompt = request.form.get("prompt") prompt = request.form.get("prompt")
if image is None or not prompt: if image is None or not prompt:
return jsonify(error="image and prompt are required"), 400 status_code = 400
error = "image and prompt are required"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
if image.mimetype not in ALLOWED_TYPES: if image.mimetype not in ALLOWED_TYPES:
return jsonify(error="unsupported image type"), 415 status_code = 415
error = "unsupported image type"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image.mimetype, prompt_chars=len(prompt), status_code=status_code,
duration_ms=0, response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
return response, status_code
image_data = image.read(MAX_IMAGE_BYTES + 1) image_data = image.read(MAX_IMAGE_BYTES + 1)
if len(image_data) > MAX_IMAGE_BYTES: if len(image_data) > MAX_IMAGE_BYTES:
return jsonify(error="image is too large"), 413 status_code = 413
error = "image is too large"
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image.mimetype, image_bytes=len(image_data), prompt_chars=len(prompt),
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
image_mime = image.mimetype
image_bytes = len(image_data)
prompt_chars = len(prompt)
try: try:
config = settings() config = settings()
response = requests.post( response = requests.post(
@@ -55,15 +103,35 @@ def recipe():
timeout=180, timeout=180,
) )
except (requests.RequestException, RuntimeError) as exc: except (requests.RequestException, RuntimeError) as exc:
return jsonify(error=str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"), 503 error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
status_code = 503
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
if response.status_code != 200: if response.status_code != 200:
try: try:
detail = response.json().get("error", {}).get("message", "Gemini request failed") detail = response.json().get("error", {}).get("message", "Gemini request failed")
except ValueError: except ValueError:
detail = "Gemini request failed" detail = "Gemini request failed"
return jsonify(error=detail), 502 error = detail
status_code = 502
response = jsonify(error=error)
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
usage_json=usage_json(usage), error=error)
return response, status_code
data = response.json() data = response.json()
return jsonify( usage = data.get("usage", {})
text=data.get("text"), result = jsonify(text=data.get("text"), usage=usage)
usage=data.get("usage", {}), record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
) user_agent=request.user_agent.string, method=request.method, path=request.path,
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
status_code=200, duration_ms=0, response_bytes=len(result.get_data()),
usage_json=usage_json(usage), error=None)
return result
+62
View File
@@ -0,0 +1,62 @@
import json
import os
import sqlite3
import time
import uuid
DEFAULT_DB_PATH = "/var/lib/recipe/metrics.sqlite3"
def db_path() -> str:
return os.environ.get("RECIPE_METRICS_DB", DEFAULT_DB_PATH)
def initialize() -> None:
path = db_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with sqlite3.connect(path) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("""
CREATE TABLE IF NOT EXISTS requests (
request_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
client_ip TEXT,
user_agent TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
image_mime TEXT,
image_bytes INTEGER,
prompt_chars INTEGER,
status_code INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
response_bytes INTEGER,
usage_json TEXT,
error TEXT
)
""")
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at)")
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code)")
def record(**values) -> None:
initialize()
columns = [
"request_id", "started_at", "client_ip", "user_agent", "method",
"path", "image_mime", "image_bytes", "prompt_chars", "status_code",
"duration_ms", "response_bytes", "usage_json", "error",
]
payload = [values.get(column) for column in columns]
with sqlite3.connect(db_path()) as connection:
connection.execute(
f"INSERT INTO requests ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
payload,
)
def request_context() -> tuple[str, str, float]:
return str(uuid.uuid4()), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), time.monotonic()
def usage_json(usage: dict) -> str:
return json.dumps(usage, ensure_ascii=True, separators=(",", ":"))
+2
View File
@@ -17,6 +17,8 @@ NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
StateDirectory=recipe
StateDirectoryMode=0770
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target