Добавим логирование запросов из /search и JSON-API, сохраним в MySQL сведения о строке запроса, числе совпадений и времени ответа. Потом сделаем простую админ-страницу аналитики: топ запросов, запросы без результатов и рекомендации, что с этим делать.
Поисковый лог - это бесплатная аналитика по мозгу пользователя:
Дальше эти данные:
Сделаем простую таблицу search_log в той же базе, что и pages:
CREATE TABLE search_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, q VARCHAR(255) NOT NULL, -- строка запроса hits INT UNSIGNED NOT NULL, -- сколько документов вернул Typesense took_ms INT UNSIGNED NOT NULL, -- время выполнения запроса к Typesense (мс) source ENUM('page', 'api', 'suggest') NOT NULL DEFAULT 'page', -- откуда запрос: HTML-страница, JSON-API, подсказки ip VARCHAR(45) NULL, -- IPv4/IPv6 (не обязательно заполнять) user_agent VARCHAR(255) NULL, -- укороченный UA created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_created_at (created_at), KEY idx_q_created_at (q, created_at), KEY idx_hits (hits) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Пояснения:
hits - общее число найденных документов (found из ответа Typesense).took_ms - время запроса к Typesense (считаем во Flask).source - удобно фильтровать аналитику отдельно по HTML-поиску и API.idx_q_created_at - для выборки по запросу за период;idx_created_at - для отчётов "за последние N дней";idx_hits - для поиска "запросов без результатов".Если не хочешь хранить IP/UA - смело выкидывай эти поля, особенно если есть требования по приватности.
search_logging.pyСделаем небольшой модуль, чтобы не плодить SQL по всему коду.
# search_logging.py from typing import Optional import time from db import get_connection # из предыдущих частей у нас есть этот helper :contentReference[oaicite:3]{index=3} def log_search( q: str, hits: int, took_ms: int, source: str = "page", ip: Optional[str] = None, user_agent: Optional[str] = None, ) -> None: """ Сохраняет одну строку в таблицу search_log. Предполагаем, что q уже обрезан по длине и лишние пробелы убраны. """ if not q: return # На всякий случай слегка ограничим длину тут if len(q) > 255: q = q[:255] conn = get_connection() try: with conn.cursor() as cursor: cursor.execute( """ INSERT INTO search_log (q, hits, took_ms, source, ip, user_agent) VALUES (%s, %s, %s, %s, %s, %s) """, (q, hits, took_ms, source, ip, user_agent[:255] if user_agent else None), ) conn.commit() finally: conn.close() def measure(func): """ Простейший декоратор, который меряет время работы функции в миллисекундах и возвращает (result, took_ms). """ def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) took_ms = int((time.perf_counter() - start) * 1000) return result, took_ms return wrapper
measure пригодится, чтобы аккуратно замерять время запроса к Typesense.
/searchУ нас уже есть маршрут /search, который ходит в Typesense и рендерит search.html. Добавим туда измерение времени и логирование.
# app.py (фрагмент: обновлённый /search) from flask import Flask, request, render_template from ts_client_search import client as ts_client # search-only ключ, Ч.5 :contentReference[oaicite:5]{index=5} from search_logging import log_search, measure app = Flask(__name__) COLLECTION_NAME = "pages" @measure def typesense_search(params: dict): """ Обёртка над вызовом Typesense, чтобы замерить took_ms. """ return ts_client.collections[COLLECTION_NAME].documents.search(params) @app.route("/search") def search(): q = (request.args.get("q") or "").strip() page_arg = request.args.get("page", "1") tag = (request.args.get("tag") or "").strip() sort = (request.args.get("sort") or "").strip() try: page = int(page_arg) if page < 1: page = 1 except ValueError: page = 1 per_page = 10 search_params = { "q": q or "*", "query_by": "title,content", "page": page, "per_page": per_page, "highlight_full_fields": "title,content", "highlight_start_tag": "<mark>", "highlight_end_tag": "</mark>", "facet_by": "tags", "max_facet_values": 50, } filter_clauses = [] if tag: filter_clauses.append(f"tags:=[{tag}]") if filter_clauses: search_params["filter_by"] = " && ".join(filter_clauses) if sort == "new": search_params["sort_by"] = "published_at:desc" elif sort == "popular": search_params["sort_by"] = "popularity:desc" # --- сам запрос к Typesense + измерение времени --- (result, took_ms) = typesense_search(search_params) hits = [] for hit in result.get("hits", []): doc = hit.get("document", {}) highlights = hit.get("highlights", []) hits.append({ "id": doc.get("id"), "slug": doc.get("slug"), "title": doc.get("title"), "snippet": doc.get("content"), "highlights": highlights, }) found = result.get("found", 0) facet_counts = result.get("facet_counts", []) pages_total = (found + per_page - 1) // per_page if found else 0 facets = {} for facet in facet_counts: field_name = facet.get("field_name") counts = facet.get("counts", []) facets[field_name] = [ {"value": c.get("value"), "count": c.get("count")} for c in counts ] # --- логирование запроса --- try: log_search( q=q or "*", hits=found, took_ms=took_ms, source="page", ip=request.remote_addr, user_agent=request.headers.get("User-Agent", "")[:255], ) except Exception as e: # В проде лучше залогировать, но не падать из-за проблем логирования app.logger.warning("Failed to log search: %s", e) return render_template( "search.html", q=q, tag=tag, sort=sort, hits=hits, found=found, page=page, pages_total=pages_total, per_page=per_page, facets=facets, took_ms=took_ms, )
Теперь каждый запрос /search складывается в search_log с числом найденных документов и временем ответа.
/api/search, /api/suggest)У нас есть лёгкие JSON-endpoint’ы для живого поиска и подсказок. Добавим туда логирование так же аккуратно.
/api/search# app_api.py (фрагмент) from flask import Flask, request, jsonify from ts_client_search import client as ts_client from search_logging import log_search, measure app = Flask(__name__) COLLECTION_NAME = "pages" @measure def typesense_api_search(params: dict): return ts_client.collections[COLLECTION_NAME].documents.search(params) @app.route("/api/search") def api_search(): q = (request.args.get("q") or "").strip() if not q: return jsonify({"hits": [], "found": 0}) try: limit = int(request.args.get("limit", "5")) except ValueError: limit = 5 limit = max(1, min(limit, 20)) search_params = { "q": q, "query_by": "title,content", "per_page": limit, "page": 1, "include_fields": "id,slug,title", "query_by_weights": "3,1", "prefix": "true", } (result, took_ms) = typesense_api_search(search_params) hits = [] for hit in result.get("hits", []): doc = hit.get("document", {}) hits.append({ "id": doc.get("id"), "slug": doc.get("slug"), "title": doc.get("title"), }) found = result.get("found", 0) # Логируем, но без IP/UA — если API зовут с фронта, они у нас есть: try: log_search( q=q, hits=found, took_ms=took_ms, source="api", ip=request.remote_addr, user_agent=request.headers.get("User-Agent", "")[:255], ) except Exception: pass return jsonify({"hits": hits, "found": found, "took_ms": took_ms})
/api/suggest (подсказки)Подсказок обычно очень много (каждое нажатие клавиши), поэтому:
/search).Для простоты можно или не логировать suggest вовсе, или сделать "тонкий" лог: только ip/q без hits.
import random from search_logging import log_search, measure SUGGEST_LOG_SAMPLE_RATE = 0.1 # логируем ~10% запросов подсказок @measure def typesense_suggest(params: dict): return ts_client.collections[COLLECTION_NAME].documents.search(params) @app.route("/api/suggest") def api_suggest(): q = (request.args.get("q") or "").strip() if not q or len(q) < 2: return jsonify({"suggestions": []}) try: limit = int(request.args.get("limit", "8")) except ValueError: limit = 8 limit = max(1, min(limit, 20)) search_params = { "q": q, "query_by": "title", "per_page": limit, "page": 1, "include_fields": "id,slug,title", "query_by_weights": "1", "prefix": "true", "num_typos": 1, "min_len_1typo": 4, "min_len_2typo": 8, } (result, took_ms) = typesense_suggest(search_params) suggestions = [] for hit in result.get("hits", []): doc = hit.get("document", {}) suggestions.append({ "id": doc.get("id"), "slug": doc.get("slug"), "title": doc.get("title"), }) found = result.get("found", 0) # Сэмплируем логирование подсказок if random.random() < SUGGEST_LOG_SAMPLE_RATE: try: log_search( q=q, hits=found, took_ms=took_ms, source="suggest", ip=request.remote_addr, user_agent=request.headers.get("User-Agent", "")[:255], ) except Exception: pass return jsonify({"suggestions": suggestions, "found": found})
Теперь, когда лог есть, можно быстро собрать полезные отчёты.
SELECT q, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 7 DAY AND source = 'page' GROUP BY q ORDER BY cnt DESC LIMIT 50;
Использование:
SELECT q, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 30 DAY AND hits = 0 AND source = 'page' GROUP BY q HAVING cnt >= 3 ORDER BY cnt DESC;
Интерпретация:
hits = 0 => контентные дыры или неправильная морфология/синонимы;SELECT q, COUNT(*) AS cnt, AVG(hits) AS avg_hits, MAX(hits) AS max_hits FROM search_log WHERE created_at >= NOW() - INTERVAL 7 DAY AND source = 'page' GROUP BY q HAVING avg_hits > 100 ORDER BY avg_hits DESC LIMIT 50;
Если по запросу в среднем >100 результатов, пользователю может быть сложно выбрать - хороший повод:
SELECT q, AVG(took_ms) AS avg_ms, MAX(took_ms) AS max_ms, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 7 DAY GROUP BY q HAVING avg_ms > 300 ORDER BY avg_ms DESC LIMIT 50;
Так можно найти:
/admin/search-analyticsДобавим простую админ-страницу (без полноценной авторизации, её оставим на совесть):
# admin_views.py from flask import Blueprint, render_template from db import get_connection bp_admin = Blueprint("admin", __name__, url_prefix="/admin") @bp_admin.route("/search-analytics") def search_analytics(): conn = get_connection() try: with conn.cursor() as cursor: # Топ запросов за 7 дней cursor.execute( """ SELECT q, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 7 DAY AND source = 'page' GROUP BY q ORDER BY cnt DESC LIMIT 20 """ ) top_queries = cursor.fetchall() # Запросы без результатов cursor.execute( """ SELECT q, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 30 DAY AND hits = 0 AND source = 'page' GROUP BY q HAVING cnt >= 3 ORDER BY cnt DESC LIMIT 20 """ ) zero_queries = cursor.fetchall() # Среднее время ответа cursor.execute( """ SELECT DATE(created_at) AS d, AVG(took_ms) AS avg_ms, COUNT(*) AS cnt FROM search_log WHERE created_at >= NOW() - INTERVAL 14 DAY GROUP BY d ORDER BY d DESC """ ) daily_speed = cursor.fetchall() finally: conn.close() return render_template( "admin/search_analytics.html", top_queries=top_queries, zero_queries=zero_queries, daily_speed=daily_speed, )
Регистрация bluepring’а в основном app.py:
# app.py from admin_views import bp_admin app.register_blueprint(bp_admin)
templates/admin/search_analytics.htmlПростой Bootstrap-вид:
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>Поисковая аналитика</h1>
<h3 class="mt-4">Топ запросов за 7 дней</h3>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Запрос</th>
<th>Количество</th>
</tr>
</thead>
<tbody>
{% for row in top_queries %}
<tr>
<td>{{ row.q }}</td>
<td>{{ row.cnt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 class="mt-4">Запросы без результатов (≥3 раза за 30 дней)</h3>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Запрос</th>
<th>Количество</th>
</tr>
</thead>
<tbody>
{% for row in zero_queries %}
<tr>
<td>{{ row.q }}</td>
<td>{{ row.cnt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 class="mt-4">Среднее время ответа по дням</h3>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Среднее время, мс</th>
<th>Запросов</th>
</tr>
</thead>
<tbody>
{% for row in daily_speed %}
<tr>
<td>{{ row.d }}</td>
<td>{{ row.avg_ms|round(1) }}</td>
<td>{{ row.cnt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
На слабом сервере:
Лог может расти бесконечно, так что:
DELETE FROM search_log WHERE created_at < NOW() - INTERVAL 90 DAY;
И важно:
Когда есть поисковая аналитика, можно:
per_page, фасеты и подсветку;