From 5f3a270e1b4a8727efb03e35e26e5bea4de9e8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D1=8C=D0=B3=D0=B0=20=D0=92=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BD=D0=B0?= Date: Wed, 1 Jul 2026 11:08:24 +0500 Subject: [PATCH] feat: live workload mode, MCP Docker/Railway deploy, new README - Add clickadvisor/workload/live_reader.py: ClickHouse HTTP live reader that queries system.query_log and returns rows in the same dict format as CSV import, keeping analyzer.py unchanged - Refactor analyzer.py: extract _accumulate_rows/_build_report helpers, add analyze_query_log_rows() for in-memory row lists (live mode) - Update workload CLI command: add --connect, --since, --user, --password flags for live mode; --query-log is now optional; both modes share the same analysis/rendering path - Add clickadvisor/mcp_server/__main__.py: supports --transport stdio|sse, --host, --port; SSE mode starts a Starlette+uvicorn server with /sse and /health endpoints for Docker/Railway deployment - Replace Dockerfile: python:3.11-slim, poetry install (no venv), PORT env var, CMD runs python -m clickadvisor.mcp_server --transport sse - Add railway.toml: one-click Railway deploy pointing at the Dockerfile - Replace README.md: new user-facing README covering all three usage modes, MCP hosting options (remote/local/stdio), architecture, DS layer, rule catalog, security posture, and roadmap Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 41 +- README.md | 542 ++++++++++++--------------- clickadvisor/cli/main.py | 60 ++- clickadvisor/mcp_server/__main__.py | 71 ++++ clickadvisor/workload/analyzer.py | 107 ++++-- clickadvisor/workload/live_reader.py | 105 ++++++ railway.toml | 17 + 7 files changed, 587 insertions(+), 356 deletions(-) create mode 100644 clickadvisor/mcp_server/__main__.py create mode 100644 clickadvisor/workload/live_reader.py create mode 100644 railway.toml diff --git a/Dockerfile b/Dockerfile index d2720a7c..70edaca9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,38 @@ -FROM python:3.12-slim AS builder +# ClickAdvisor MCP Server +# Supports: +# Local: docker run -p 8000:8000 clickadvisor-mcp +# Railway: auto-detects PORT env var -WORKDIR /app +FROM python:3.11-slim -RUN pip install poetry==2.4.1 +WORKDIR /app -COPY pyproject.toml poetry.lock ./ +# Install system deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* -RUN poetry config virtualenvs.in-project true \ - && poetry install --no-root --without dev +# Install poetry +RUN pip install --no-cache-dir poetry==1.8.2 -COPY . . +# Copy only dependency files first (layer cache) +COPY pyproject.toml poetry.lock* ./ -RUN poetry install --without dev +# Install dependencies without dev extras +RUN poetry config virtualenvs.create false \ + && poetry install --no-interaction --no-ansi --only main -FROM python:3.12-slim +# Copy source +COPY clickadvisor/ ./clickadvisor/ +COPY README.md ./ -WORKDIR /app +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8000}/health || exit 1 -COPY --from=builder /app /app +# Railway / Render inject PORT automatically; fall back to 8000 locally +ENV PORT=8000 -ENV PATH="/app/.venv/bin:$PATH" +EXPOSE ${PORT} -ENTRYPOINT ["chadvisor"] -CMD ["--help"] +CMD ["sh", "-c", "python -m clickadvisor.mcp_server --transport sse --host 0.0.0.0 --port ${PORT}"] diff --git a/README.md b/README.md index eee40218..14838855 100644 --- a/README.md +++ b/README.md @@ -1,415 +1,351 @@ # ClickAdvisor -![Python](https://img.shields.io/badge/python-3.11%2B-blue) -![License](https://img.shields.io/badge/license-MIT-blue) -![ClickHouse](https://img.shields.io/badge/ClickHouse-SQL%20advisor-ffcc01) -![Rules](https://img.shields.io/badge/паттерны-119-brightgreen) -![Benchmark](https://img.shields.io/badge/benchmark-222%20cases-brightgreen) - -[![Русский](https://img.shields.io/badge/README-Русский-2ea44f?style=for-the-badge)](README.md) -[![English](https://img.shields.io/badge/README-English-0969da?style=for-the-badge)](README.en.md) - -> Локальный CLI и MCP-сервер для анализа ClickHouse SQL. -> Находит антипаттерны, предлагает безопасные варианты переписывания и объясняет, -> почему совет применим именно для ClickHouse. - -ClickAdvisor помогает DBA, дата-инженерам и backend-разработчикам быстрее -разбирать медленные или рискованные ClickHouse-запросы. Он принимает SQL, -опционально учитывает DDL-схему, EXPLAIN и контекст окружения, применяет -детерминированные правила и возвращает отчет в консоли, JSON или Markdown. - -[Сайт проекта](https://clickadvisor.lovable.app) - -## Зачем ClickAdvisor, если уже есть ChatGPT? - -ChatGPT, Claude и другие ассистенты отлично помогают думать, писать код и -быстро получать гипотезы. ClickAdvisor закрывает другой слой задачи: -воспроизводимую проверку ClickHouse SQL там, где важны предсказуемость, -версионированные правила, след аудита и контроль над данными. - -- У каждого срабатывания есть `rule_id`, уровень доверия, версия ClickHouse и условия применимости. -- Фильтр по версии скрывает правила, которые не подходят для указанной версии ClickHouse. -- Модель уровней отделяет доказуемые переписывания от приближенных и рекомендательных советов. -- Режим `--mode explain` объясняет принцип работы ClickHouse простым языком. -- Локальный поиск по базе знаний добавляет ссылки на документацию, но не заменяет движок правил. - -Для enterprise-среды это особенно важно: в банках, телекоме, госсекторе и -других регулируемых компаниях SQL, DDL, EXPLAIN и сведения об окружении часто -попадают под compliance-требования. - -По умолчанию ClickAdvisor работает локально. Если вы используете CLI, Docker или -CI-запуск внутри своего контура, пользовательский SQL не уходит на внешние -серверы и не отправляется в генеративную языковую модель без вашего явного -действия. MCP-сервер тоже запускается локально, но если вы подключаете его к -внешнему AI-клиенту, то передача SQL уже зависит от того, что именно вы -отправляете этому клиенту и какие политики безопасности действуют в вашей -организации. - -## Что умеет - -- Анализировать ClickHouse SQL без подключения к базе. -- Учитывать версию ClickHouse через `--ch-version` или HTTP `SELECT version()`. -- Читать дополнительные входы: `--schema`, `--explain`, `--environment`. -- Возвращать отчеты в `console`, `json` и `markdown`. -- Подключаться к AI-агентам как локальный MCP-сервер. -- Добавлять локальные ссылки на документацию ClickHouse и Altinity KB через встроенный Qdrant. -- Сравнивать варианты переписывания через `EXPLAIN ESTIMATE`, если явно передан `--connect`. -- Строить prototype workload-отчеты по sanitized CSV export из `system.query_log`. - -## Цифры проекта - -| Что измеряется | Значение | -|---|---:| -| Паттерны анализа SQL/config/workload | 119 | -| Правила переписывания и рекомендаций `R-*` | 74 | -| Детекторы антипаттернов `D-*` | 25 | -| Правила окружения `E-*` | 20 | -| Benchmark YAML-кейсы всего | 327 | -| Кейсы `synthetic_expanded` | 222 | -| Unit-, validation- и benchmark-тесты без integration | 325 | - -## Архитектура - -```mermaid -flowchart LR - SQL[SQL-запрос] --> Parser[sqlglot parser] - Schema[DDL-схема] --> Context[QueryContext] - Env[Контекст окружения] --> Context - Explain[EXPLAIN] --> Context - Parser --> Context - Context --> Rules[119 правил] - Rules --> Report[Console / JSON / Markdown / MCP] - KB[Локальная база знаний] --> Retrieval[Ссылки на документацию] - Retrieval --> Report +**Local-first ClickHouse Performance Advisor** + +ClickAdvisor finds risky SQL patterns before they cause production incidents. +It runs entirely on your machine — no SQL, DDL, or query context ever leaves your environment. + +```bash +# Analyze a single query +chadvisor analyze --sql "SELECT * FROM events FINAL WHERE date > '2024-01-01'" + +# Analyze a workload from query_log export +chadvisor workload --query-log query_log.csv --output-format markdown + +# Analyze a live ClickHouse instance +chadvisor workload --connect http://localhost:8123 --since 24 ``` -Подробнее: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +--- + +## Why ClickAdvisor + +Generic SQL assistants see SQL syntax. ClickAdvisor knows ClickHouse: + +- **Deterministic rule engine** — every finding has a `rule_id`, tier, severity, and confidence. No guessing. +- **ClickHouse-specific** — `FINAL`, sparse primary key, marks/parts, `PREWHERE`, `LowCardinality`, distributed joins, MergeTree mutations. +- **Local-first** — your SQL and query logs never touch an external LLM at runtime. +- **MCP-ready** — AI agents (Claude, Cursor, Codex) call ClickAdvisor as a tool, not as a source of truth. +- **Workload-aware** — goes beyond single queries to rank your most expensive query groups by actual resource consumption. -## Быстрый старт +--- + +## Installation -### Запуск из исходников +**Requirements:** Python 3.11+, [Poetry](https://python-poetry.org/) ```bash git clone https://github.com/olyannaa/clickadvisor.git cd clickadvisor poetry install -poetry run chadvisor analyze --sql query.sql +poetry run chadvisor --help ``` -### Через Docker - +For live mode, add `httpx`: ```bash -docker build -t clickadvisor . -docker run --rm -v "$(pwd)":/queries clickadvisor analyze --sql /queries/query.sql +poetry install --extras live ``` -## Реальный пример - -`query.sql`: +--- -```sql -SELECT - e.country, - COUNT(DISTINCT e.user_id) AS unique_users, - sumIf(e.revenue, e.status = 'paid') AS paid_revenue -FROM -( - SELECT * - FROM events FINAL - WHERE message LIKE '%timeout%' - AND (country = 'RU' OR country = 'KZ' OR country = 'BY') -) AS e -JOIN users AS u - ON toUInt64(e.user_id) = u.id -GROUP BY e.country -HAVING e.country = 'RU' -ORDER BY paid_revenue DESC; -``` +## Three modes of use -Запуск: +### 1 — Single query analysis ```bash -poetry run chadvisor analyze \ - --sql query.sql \ - --ch-version 25.3 \ - --output-format markdown \ - --no-retrieval +chadvisor analyze \ + --sql "SELECT countDistinct(user_id) FROM events WHERE toDate(ts) = today()" \ + --schema schema.sql \ + --format markdown ``` -На этом запросе ClickAdvisor находит 10 срабатываний, включая: +Example output: -- `R-001`: `COUNT(DISTINCT user_id)` можно заменить на `uniqExact(user_id)`; -- `R-002`: если допустима приблизительная оценка, можно рассмотреть `uniq(user_id)`; -- `D-005` и `R-102`: `LIKE '%...'` требует осторожности и может нуждаться в skip-index; -- `D-007`: `FINAL` может быть дорогим на больших MergeTree-таблицах; -- `D-011`, `R-008`, `R-020`: приведение типа вокруг JOIN-ключа стоит проверить; -- `R-011`: часть условий из `HAVING` можно перенести в `WHERE`. -- `R-014`: `GROUP BY` по строковой колонке может быть дорогим и требует проверки типа/кардинальности. +``` +## ClickAdvisor findings -Реальный фрагмент вывода CLI: +### D-007 · high · Function on filter column +`toDate(ts)` wraps the primary key column. ClickHouse cannot use the sparse index. +→ Rewrite: `WHERE ts >= today() AND ts < today() + INTERVAL 1 DAY` -![Пример вывода ClickAdvisor](docs/assets/readme-example-output.svg) +### R-102 · medium · Exact COUNT DISTINCT on high-cardinality column +Consider `uniq(user_id)` if an approximate result is acceptable (~2% error). +Exact `countDistinct` materializes all values in memory. +``` -## Использование CLI +### 2 — Workload analysis (CSV mode) -### Базовый анализ +Export a sanitized snapshot from ClickHouse: -```bash -poetry run chadvisor analyze --sql query.sql +```sql +SELECT + query, query_duration_ms, read_rows, read_bytes, memory_usage, + event_time, user, query_kind +FROM system.query_log +WHERE type = 'QueryFinish' + AND event_time >= now() - INTERVAL 24 HOUR +FORMAT CSVWithNames ``` -### Анализ с версией ClickHouse - ```bash -poetry run chadvisor analyze --sql query.sql --ch-version 25.3 +chadvisor workload \ + --query-log query_log.csv \ + --top-n 5 \ + --output-format markdown \ + --output report.md ``` -Версия используется для фильтрации правил по `ch_version_introduced`. - -### Автоопределение версии через HTTP API +Example output: -```bash -poetry run chadvisor analyze --sql query.sql \ - --connect http://localhost:8123 \ - --ch-user default \ - --ch-password secret ``` +# ClickHouse workload risk report -ClickAdvisor выполнит только `SELECT version()` и нормализует ответ, например -`25.3.2.39` -> `25.3`. +## #1 · Dashboard fingerprint 8f3a… · RISK: HIGH +Executions/day: 1 240 | Avg latency: 3.4 s | P95: 18.2 s +Total read/day: 2.8 TB | Total CPU time: 4.7 h -### Режим объяснений +Findings: + D-007 · high · Function on filter column + D-003 · high · FINAL on large table (est. 2× scan overhead) + R-102 · medium · Exact COUNT DISTINCT -```bash -poetry run chadvisor analyze --sql query.sql --mode explain +→ DBA actions: verify FINAL necessity, inspect selected marks, test range rewrite ``` -В этом режиме отчет объясняет не только что заменить, но и почему это важно для -ClickHouse: sparse primary key index, granules, порядок выполнения `WHERE` / -`HAVING`, стоимость `FINAL`, разницу между `UNION` и `UNION ALL` и так далее. +### 3 — Live mode -### Форматы вывода +Connect directly to a running ClickHouse instance: ```bash -poetry run chadvisor analyze --sql query.sql --output-format console -poetry run chadvisor analyze --sql query.sql --output-format json -poetry run chadvisor analyze --sql query.sql --output-format markdown +chadvisor workload \ + --connect http://localhost:8123 \ + --user default \ + --since 24 \ + --top-n 10 \ + --output-format markdown ``` -`console` удобен для локальной диагностики, `json` — для CI/CD, `markdown` — -для PR-комментариев и MCP-ответов. +ClickAdvisor reads `system.query_log` for the last N hours, groups queries by normalized fingerprint, ranks by total resource cost, and runs the full rule engine on each group. -### EXPLAIN ESTIMATE +> **Security note:** Only `SELECT` queries from `system.query_log` are read. No data is written. Credentials stay local. -```bash -poetry run chadvisor analyze --sql query.sql \ - --connect http://localhost:8123 \ - --ch-user default \ - --ch-password secret \ - --explain-estimate -``` +--- -ClickAdvisor сравнивает исходный SQL и вариант переписывания через `EXPLAIN -ESTIMATE`. Запрос не выполняется, `ANALYZE` не запускается, пользовательские -данные не читаются. Используется только оценка планировщика ClickHouse. +## MCP integration -### Схема, EXPLAIN и окружение +ClickAdvisor exposes a [Model Context Protocol](https://modelcontextprotocol.io/) server so AI agents can call it as a trusted tool — not generate advice themselves. -```bash -poetry run chadvisor analyze --sql query.sql \ - --schema schema.sql \ - --environment environment.json \ - --explain explain.json ``` +AI agent (Claude / Cursor / Codex) + │ + │ MCP tool call: analyze_sql(sql, schema) + ▼ +ClickAdvisor rule engine ←── deterministic, local, no LLM + │ + │ structured findings: rule_id, severity, rewrite example + ▼ +AI agent presents findings to the user +``` + +The agent gets structured, explainable findings. It never invents ClickHouse advice. -`environment.json` передает настройки, характеристики железа, факты о -кластере/workload и системные метрики для `E-*` и части рекомендательных правил -Tier 2. Если файл окружения не передан, правила окружения не срабатывают. +### Option A — Remote MCP server (for demos and team use) -Минимальный пример: +Connect Claude Desktop or any MCP client to the public instance: ```json { - "settings": { - "max_threads": 64, - "max_memory_usage": 90000000000, - "join_use_nulls": true - }, - "hardware": { - "cpu_cores": 16, - "ram_bytes": 128000000000, - "disk_type": "hdd" - }, - "workload": { - "interactive_queries": true, - "large_join": true, - "bulk_inserts": true, - "insert_format": "JSONEachRow" - }, - "cluster": { - "shards": 4, - "replicas": 2, - "has_local_replica": true + "mcpServers": { + "clickadvisor": { + "transport": "sse", + "url": "https://clickadvisor-mcp.up.railway.app/sse" + } } } ``` -## База знаний и рекомендации из документации +No installation needed. The remote server runs the rule engine only — your SQL is processed in memory and never stored. -База знаний собирается в `/data/kb/` из документации ClickHouse, Altinity KB, -ClickHouse blog и release notes. Для локального semantic search нужно -проиндексировать Markdown-чанки: +### Option B — Local MCP server (for enterprise / NDA environments) -```bash -poetry run chadvisor index-kb -``` - -Повторная индексация: +Run the server on your own machine in one command: ```bash -poetry run chadvisor index-kb --reindex +docker run -p 8000:8000 ghcr.io/olyannaa/clickadvisor-mcp:latest ``` -Выбор embedding-модели: - -```bash -poetry run chadvisor index-kb --embedding-model multilingual-e5-small -poetry run chadvisor index-kb --embedding-model minilm-l6 -``` +Then add to Claude Desktop config: -После индексации появится локальная директория `.qdrant_db`. Если она есть, -`analyze` по умолчанию добавляет отдельную секцию с релевантными фрагментами -документации. - -## MCP-сервер - -ClickAdvisor можно подключить к AI-агентам как локальный MCP-сервер: - -```bash -poetry run chadvisor mcp-server +```json +{ + "mcpServers": { + "clickadvisor": { + "transport": "sse", + "url": "http://localhost:8000/sse" + } + } +} ``` -Пример для `claude_desktop_config.json`: +Or via stdio (no Docker required): ```json { "mcpServers": { "clickadvisor": { "command": "poetry", - "args": ["run", "chadvisor", "mcp-server"], + "args": ["run", "python", "-m", "clickadvisor.mcp_server"], "cwd": "/path/to/clickadvisor" } } } ``` -Доступные MCP tools: - -- `analyze_query` — Markdown-отчет по SQL; -- `analyze_query_json` — структурированный JSON; -- `list_rules` — список зарегистрированных правил; -- `detect_ch_version` — определение версии ClickHouse через HTTP API. +### Available MCP tools -Подробности: [docs/MCP.md](docs/MCP.md). +| Tool | Description | +|------|-------------| +| `analyze_sql` | Analyze a single SQL query | +| `analyze_with_schema` | Analyze SQL with DDL context | +| `analyze_workload_csv` | Analyze query_log CSV export | +| `list_rules` | List all available rules with descriptions | +| `explain_finding` | Get detailed explanation for a rule_id | -## Workload analyzer prototype +--- -Для перехода от анализа одного SQL к DBA review queue есть прототип анализа -sanitized `system.query_log` CSV: +## Architecture -```bash -poetry run chadvisor workload \ - --query-log examples/query_log_sample.csv \ - --output-format markdown \ - --top-n 3 +``` +SQL / DDL / EXPLAIN / query_log + │ + ▼ + ┌─────────────────────────┐ + │ Deterministic rule │ ← trusted core, no LLM + │ engine │ + │ · 40+ ClickHouse rules │ + │ · rule_id / tier / │ + │ severity / confidence│ + └────────────┬────────────┘ + │ + ┌──────────┼──────────┐ + ▼ ▼ ▼ + CLI/JSON Markdown MCP server + reports reports (SSE / stdio) + │ + ▼ + Workload analyzer + · fingerprint grouping + · resource aggregation + · top-N DBA review queue + │ + ▼ + DS experiment layer + · risk classification + · triage / prioritization + · method comparison ``` -Он группирует похожие запросы по normalized fingerprint, считает executions, -total/avg/p95 latency, read rows/bytes и memory, затем прогоняет representative -query через rule engine и ранжирует top opportunities. +LLMs are used as **interface and automation** (MCP, research pipelines) — never as the source of performance findings. -Подробности: [docs/workload.md](docs/workload.md). +--- -## Метрики качества +## Data Science layer -Текущие воспроизводимые метрики на 2026-06-30: +ClickAdvisor includes a research layer that formalizes evaluation quality and prepares impact-based prioritization. -| Поверхность оценки | Данные | Метрика | -|---|---|---:| -| Детерминированные правила | `222` SQL/schema/env benchmark-кейса | precision `1.000`, recall `1.000`, F1 `1.000` | -| ML-классификатор | train/test split `synthetic_expanded_v1` | лучший test macro F1 `0.691`, лучший test micro F1 `0.988` | -| Поиск по документации | `20` явных пар запрос -> релевантная документация | MRR@3 `0.517` на MiniLM-L6 | -| Risk-label DS контур | `20 235` SQL-записей, group split + holdout | RF holdout macro F1 `0.949`, measured-only macro F1 `0.595` | -| Workload prototype | sample sanitized `query_log` CSV | normalized groups + top-N risk report | +**Dataset:** 20 235 ClickHouse SQL queries from ClickBench, TPC-H, TPC-DS, SSB, ClickHouse functional tests, and open-source projects. Each query carries: +- `rule_findings` — deterministic rule engine output +- `measured_metrics` — `read_rows`, `query_duration_ms`, `memory_usage` from local replay +- `final_risk_label` — reconciled `low / medium / high` from both signals +- `label_source` — `rule_only / measured_only / both` -Что именно оценивалось: +**Baseline results (holdout):** -- Детерминированные правила: строгая проверка, что analyzer возвращает ровно ожидаемые `rule_id`. -- Абляция классификатора: multi-label классификация по AST/SQL features. -- Абляция поиска по документации: `MRR@3` по явной разметке запрос -> релевантные URL/keywords документации. +| Model | Macro-F1 | +|-------|----------| +| Dummy (majority) | 0.278 | +| TF-IDF + Logistic Regression | 0.882 | +| Structured + Rule features + LR | 0.837 | +| **Random Forest (all features)** | **0.949** | +| CatBoost | 0.871 | -Запуск расширенного synthetic benchmark: +**Key finding:** RF F1=0.949 overall, but on `measured_only` subset (no rule signal) it drops to F1=0.595. This confirms that the rule engine is the production runtime — ML adds value as a triage and prioritization layer, not as a replacement. -```bash -poetry run python scripts/eval/run_benchmark.py \ - --cases-dir benchmark/cases/synthetic_expanded \ - --mode strict -``` +Full experiment report: [`docs/experiments/risk_labeling_ds_summary.md`](docs/experiments/risk_labeling_ds_summary.md) -Подробная методика: [docs/evaluation.md](docs/evaluation.md), -[docs/experiments/classifier_ablation.md](docs/experiments/classifier_ablation.md), -[docs/experiments/retrieval_ablation.md](docs/experiments/retrieval_ablation.md), -[docs/experiments/risk_labeling_ds_summary.md](docs/experiments/risk_labeling_ds_summary.md). +--- -## Безопасность и данные +## Rule catalog -ClickAdvisor не выполняет пользовательский SQL-запрос для измерения speedup. -При подключении к ClickHouse используются только: +ClickAdvisor covers six risk families: -- `SELECT version()` для определения версии; -- `EXPLAIN ESTIMATE ...` при явном флаге `--explain-estimate`. +| Family | Examples | +|--------|---------| +| MergeTree & pruning | `FINAL`, functions on PK columns, bad partition pruning, missing `PREWHERE` | +| Aggregation & cardinality | `COUNT(DISTINCT)`, high-cardinality `GROUP BY`, memory-heavy aggregate states | +| Joins & distributed | Casts around join keys, large right-side joins, distributed join overhead | +| Schema design | `LowCardinality` candidates, `Nullable` overuse, oversized integer types | +| Settings & environment | `max_threads` vs CPU, `max_memory_usage`, async insert settings | +| Safety & operations | Mutations on large tables, `DELETE` without `WHERE`, `OPTIMIZE FINAL` misuse | -Для базового анализа достаточно SQL-файла. Схема, EXPLAIN и подключение к -кластеру — опциональные источники контекста. +Each rule has: rule card · positive/negative tests · benchmark case · tier · confidence. -Для enterprise-внедрения ключевой принцип такой: ClickAdvisor можно запускать в -контуре компании, CI/CD или локальной среде инженера без отправки SQL, DDL, -EXPLAIN и environment-контекста во внешние LLM или API-сервисы. Это снижает риски для -compliance, банковской тайны, персональных данных, коммерческой тайны и -внутренних naming conventions. +Browse the catalog: [`clickadvisor/rules/`](clickadvisor/rules/) -Подробности: [docs/security-local-first.md](docs/security-local-first.md). +--- -## Разработка +## Security & local-first posture -```bash -poetry install -poetry run ruff check clickadvisor tests scripts -poetry run mypy clickadvisor -poetry run pytest --ignore=tests/integration -poetry run python scripts/eval/run_benchmark.py -``` +ClickAdvisor is designed for environments where SQL, DDL, and query logs are sensitive: + +- **No external calls at runtime** — the rule engine is pure Python, offline by default. +- **MCP server processes in memory** — no query storage, no logging of SQL content. +- **Live mode** — reads `system.query_log` with a read-only user, no writes. +- **query_log export** — literals can be redacted before export; the rule engine works on normalized fingerprints. +- **LLM is optional** — agents use MCP to call ClickAdvisor; the LLM never generates trusted findings. -Integration test для version detection ожидает ClickHouse HTTP endpoint на -`localhost:8123`. В GitHub Actions он поднимается как сервисный контейнер. +For strict environments: run the Docker image on-premise with no outbound network access. -## AI-assisted разработка +Full security doc: [`docs/security-local-first.md`](docs/security-local-first.md) -Codex и Claude использовались системно в разработке: для ревью архитектурных -решений, генерации вариантов тестов, документации и проверки согласованности -плана с кодом. Они не входят в доверенный путь выполнения ClickAdvisor: -рекомендации CLI/MCP формируются движком правил, ML-слоем оценки и локальным -поиском по документации. +--- -## Что не заявляется как готовое +## Roadmap -- Продуктовая генеративная LLM в доверенном пути выполнения. -- Live-анализ `query_log` через `--connect --since`; сейчас есть CSV prototype. -- Автоматические DDL-изменения. -- Выполнение `ANALYZE` или реальный replay запросов на данных. -- Автоматическое применение Tier 2 design/storage рекомендаций без проверки DBA. +- [x] Single-query advisor (CLI + MCP) +- [x] Workload advisor — CSV mode +- [x] Live mode (`--connect`) +- [x] DS risk classification layer +- [ ] `EXPLAIN`-based before/after comparator +- [ ] ClickHouse Cloud / Altinity integration +- [ ] Impact ranking by `read_bytes × executions_per_day` +- [ ] Workload diff between time windows (before/after deploy) --- -[![Русский](https://img.shields.io/badge/README-Русский-2ea44f?style=for-the-badge)](README.md) -[![English](https://img.shields.io/badge/README-English-0969da?style=for-the-badge)](README.en.md) +## Development + +```bash +# Run tests +poetry run pytest --ignore=tests/integration -q + +# Type check +poetry run mypy clickadvisor + +# Lint +poetry run ruff check clickadvisor tests scripts + +# Validate rule catalog +poetry run python scripts/rules/validate_catalog.py + +# Run benchmark suite +poetry run python scripts/eval/run_benchmark.py \ + --cases-dir benchmark/cases/synthetic_expanded \ + --mode strict +``` + +--- + +## License + +MIT © 2024 olyannaa diff --git a/clickadvisor/cli/main.py b/clickadvisor/cli/main.py index 3080770a..4339d7f3 100644 --- a/clickadvisor/cli/main.py +++ b/clickadvisor/cli/main.py @@ -158,17 +158,37 @@ def analyze( @app.command() def workload( query_log: Annotated[ - Path, + Path | None, typer.Option(help="Sanitized CSV export from system.query_log"), - ], + ] = None, + connect: Annotated[ + str | None, + typer.Option(help="ClickHouse HTTP URL for live mode, e.g. http://localhost:8123"), + ] = None, + since: Annotated[ + int, + typer.Option(help="Hours back to read from system.query_log (live mode)"), + ] = 24, + ch_user: Annotated[str, typer.Option("--user", help="ClickHouse user (live mode)")] = "default", + ch_password: Annotated[ + str, typer.Option("--password", help="ClickHouse password (live mode)") + ] = "", top_n: Annotated[int, typer.Option(help="Number of normalized query groups to show")] = 10, ch_version: Annotated[str | None, typer.Option(help="24.3")] = None, output_format: Annotated[str, typer.Option(help="console|json|markdown")] = "console", output: Annotated[Path | None, typer.Option(help="Write report to file")] = None, ) -> None: - """Analyze a sanitized query_log CSV export and rank workload risks.""" + """Analyze ClickHouse workload risk. + + Two modes: + + CSV: chadvisor workload --query-log query_log.csv + + Live: chadvisor workload --connect http://localhost:8123 --since 24 + """ from clickadvisor.workload.analyzer import ( analyze_query_log_csv, + analyze_query_log_rows, render_workload_json, render_workload_markdown, workload_report_to_dict, @@ -178,8 +198,40 @@ def workload( raise typer.BadParameter("output_format must be one of: console, json, markdown") if top_n <= 0: raise typer.BadParameter("top_n must be positive") + if connect and query_log: + typer.echo("Error: use --connect OR --query-log, not both.", err=True) + raise typer.Exit(1) + if not connect and not query_log: + typer.echo("Error: provide --connect or --query-log .", err=True) + raise typer.Exit(1) + + if connect: + from clickadvisor.workload.live_reader import ClickHouseLiveReader, LiveReaderConfig + + reader_cfg = LiveReaderConfig( + url=connect, + user=ch_user, + password=ch_password, + since_hours=since, + ) + reader = ClickHouseLiveReader(reader_cfg) + + if not reader.check_connection(): + typer.echo(f"Error: cannot reach ClickHouse at {connect}", err=True) + raise typer.Exit(1) + + typer.echo(f"Connected to {connect}. Reading last {since}h from system.query_log...") + rows = reader.fetch() + + if not rows: + typer.echo("No query_log rows found for the given time range.") + raise typer.Exit(0) + + report = analyze_query_log_rows(rows, source=connect, top_n=top_n, ch_version=ch_version) + else: + assert query_log is not None + report = analyze_query_log_csv(query_log, top_n=top_n, ch_version=ch_version) - report = analyze_query_log_csv(query_log, top_n=top_n, ch_version=ch_version) if output_format == "json": rendered = render_workload_json(report) else: diff --git a/clickadvisor/mcp_server/__main__.py b/clickadvisor/mcp_server/__main__.py new file mode 100644 index 00000000..7d2ac3ce --- /dev/null +++ b/clickadvisor/mcp_server/__main__.py @@ -0,0 +1,71 @@ +""" +Entry point for `python -m clickadvisor.mcp_server`. + +Supports --transport stdio (default) and --transport sse for Docker/Railway. +""" + +from __future__ import annotations + +import argparse + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ClickAdvisor MCP server") + parser.add_argument( + "--transport", + choices=["stdio", "sse"], + default="stdio", + help="Transport to use (default: stdio)", + ) + parser.add_argument("--host", default="0.0.0.0", help="Host to bind (SSE mode)") + parser.add_argument("--port", type=int, default=8000, help="Port to bind (SSE mode)") + return parser.parse_args() + + +def _run_sse(host: str, port: int) -> None: + import uvicorn + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import Response + from starlette.routing import Mount, Route + + from clickadvisor.mcp_server.server import server + + sse_transport = SseServerTransport("/messages/") + + async def handle_sse(request: Request) -> Response: + async with sse_transport.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await server.run( + streams[0], streams[1], server.create_initialization_options() + ) + return Response() + + async def handle_health(request: Request) -> Response: + return Response("ok", media_type="text/plain") + + starlette_app = Starlette( + routes=[ + Route("/health", handle_health), + Route("/sse", handle_sse), + Mount("/messages/", app=sse_transport.handle_post_message), + ] + ) + + uvicorn.run(starlette_app, host=host, port=port) + + +def main() -> None: + args = _parse_args() + + if args.transport == "sse": + _run_sse(args.host, args.port) + else: + from clickadvisor.mcp_server.server import run + run() + + +if __name__ == "__main__": + main() diff --git a/clickadvisor/workload/analyzer.py b/clickadvisor/workload/analyzer.py index e8f682d6..3586fc61 100644 --- a/clickadvisor/workload/analyzer.py +++ b/clickadvisor/workload/analyzer.py @@ -66,43 +66,43 @@ class _GroupAccumulator: memory_values: list[int] = field(default_factory=list) -def analyze_query_log_csv( - path: Path, - *, - top_n: int = 10, - ch_version: str | None = None, -) -> WorkloadReport: - """Analyze a sanitized ClickHouse query_log CSV export. - - The analyzer reads metadata and query text only. It does not connect to - ClickHouse and does not execute user SQL. - """ +def _accumulate_rows( + rows: list[dict[str, str]], +) -> tuple[int, int, dict[str, _GroupAccumulator]]: rows_read = 0 rows_used = 0 groups: dict[str, _GroupAccumulator] = {} - - with path.open(encoding="utf-8", newline="") as handle: - reader = csv.DictReader(handle) - for row in reader: - rows_read += 1 - sql = first_text(row, QUERY_COLUMNS) - if not sql: - continue - rows_used += 1 - normalized = normalize_sql(sql) - fingerprint = fingerprint_sql(normalized) - accumulator = groups.setdefault( - fingerprint, - _GroupAccumulator( - normalized_sql=normalized, - representative_sql=sql.strip(), - ), - ) - accumulator.durations_ms.append(first_int(row, DURATION_COLUMNS)) - accumulator.read_rows += first_int(row, READ_ROWS_COLUMNS) - accumulator.read_bytes += first_int(row, READ_BYTES_COLUMNS) - accumulator.memory_values.append(first_int(row, MEMORY_COLUMNS)) - + for row in rows: + rows_read += 1 + sql = first_text(row, QUERY_COLUMNS) + if not sql: + continue + rows_used += 1 + normalized = normalize_sql(sql) + fingerprint = fingerprint_sql(normalized) + accumulator = groups.setdefault( + fingerprint, + _GroupAccumulator( + normalized_sql=normalized, + representative_sql=sql.strip(), + ), + ) + accumulator.durations_ms.append(first_int(row, DURATION_COLUMNS)) + accumulator.read_rows += first_int(row, READ_ROWS_COLUMNS) + accumulator.read_bytes += first_int(row, READ_BYTES_COLUMNS) + accumulator.memory_values.append(first_int(row, MEMORY_COLUMNS)) + return rows_read, rows_used, groups + + +def _build_report( + source: str, + rows_read: int, + rows_used: int, + groups: dict[str, _GroupAccumulator], + *, + top_n: int, + ch_version: str | None, +) -> WorkloadReport: analyzed_groups = [ build_query_group(fingerprint, accumulator, ch_version=ch_version) for fingerprint, accumulator in groups.items() @@ -117,7 +117,7 @@ def analyze_query_log_csv( reverse=True, ) return WorkloadReport( - source=str(path), + source=source, rows_read=rows_read, rows_used=rows_used, group_count=len(analyzed_groups), @@ -126,6 +126,43 @@ def analyze_query_log_csv( ) +def analyze_query_log_csv( + path: Path, + *, + top_n: int = 10, + ch_version: str | None = None, +) -> WorkloadReport: + """Analyze a sanitized ClickHouse query_log CSV export. + + The analyzer reads metadata and query text only. It does not connect to + ClickHouse and does not execute user SQL. + """ + with path.open(encoding="utf-8", newline="") as handle: + raw_rows: list[dict[str, str]] = list(csv.DictReader(handle)) + rows_read, rows_used, groups = _accumulate_rows(raw_rows) + return _build_report( + str(path), rows_read, rows_used, groups, top_n=top_n, ch_version=ch_version + ) + + +def analyze_query_log_rows( + rows: list[dict[str, str]], + *, + source: str, + top_n: int = 10, + ch_version: str | None = None, +) -> WorkloadReport: + """Analyze query_log rows fetched from a live ClickHouse instance. + + Accepts the same row format as analyze_query_log_csv (list of dicts with + string values), so ClickHouseLiveReader output can be passed directly. + """ + rows_read, rows_used, groups = _accumulate_rows(rows) + return _build_report( + source, rows_read, rows_used, groups, top_n=top_n, ch_version=ch_version + ) + + def normalize_sql(sql: str) -> str: text = STRING_LITERAL_RE.sub("?", sql) text = NUMBER_LITERAL_RE.sub("?", text) diff --git a/clickadvisor/workload/live_reader.py b/clickadvisor/workload/live_reader.py new file mode 100644 index 00000000..49fd1d24 --- /dev/null +++ b/clickadvisor/workload/live_reader.py @@ -0,0 +1,105 @@ +""" +clickadvisor/workload/live_reader.py + +Reads query_log directly from a running ClickHouse instance via HTTP API. +Produces the same row format as CSV import so analyzer.py stays unchanged. +""" + +from __future__ import annotations + +import csv +import io +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +import httpx + +logger = logging.getLogger(__name__) + +# Columns we need — matches query_log_sample.csv schema +_QUERY = """ +SELECT + query, + query_duration_ms, + read_rows, + read_bytes, + memory_usage, + toString(event_time) AS event_time, + user, + query_kind, + exception_code, + result_rows +FROM system.query_log +WHERE + type = 'QueryFinish' + AND event_time >= '{since}' + AND query_kind IN ('Select', 'AsyncInsertFlush') + AND query NOT ILIKE '%system.query_log%' + AND query NOT ILIKE '%system.processes%' +ORDER BY query_duration_ms DESC +LIMIT {limit} +FORMAT CSVWithNames +""".strip() + + +@dataclass +class LiveReaderConfig: + url: str # e.g. http://localhost:8123 + user: str = "default" + password: str = "" + since_hours: int = 24 + limit: int = 10_000 + timeout: float = 30.0 + + +class ClickHouseLiveReader: + """Minimal HTTP client for system.query_log extraction.""" + + def __init__(self, config: LiveReaderConfig) -> None: + self.config = config + self._base_url = config.url.rstrip("/") + + def fetch(self) -> list[dict[str, str]]: + """ + Returns list of row-dicts with the same keys as query_log_sample.csv. + Raises httpx.HTTPStatusError on ClickHouse errors. + """ + since_dt = datetime.now(tz=UTC) - timedelta(hours=self.config.since_hours) + since_str = since_dt.strftime("%Y-%m-%d %H:%M:%S") + + sql = _QUERY.format(since=since_str, limit=self.config.limit) + + logger.info( + "Fetching query_log from %s (since %s, limit %d)", + self._base_url, + since_str, + self.config.limit, + ) + + response = httpx.post( + self._base_url, + content=sql.encode(), + params={"user": self.config.user, "password": self.config.password}, + headers={"Content-Type": "text/plain; charset=utf-8"}, + timeout=self.config.timeout, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + # ClickHouse puts the error message in the body + logger.error("ClickHouse returned error:\n%s", response.text[:2000]) + raise exc + + rows = list(csv.DictReader(io.StringIO(response.text))) + logger.info("Fetched %d rows from query_log", len(rows)) + return rows + + def check_connection(self) -> bool: + """Quick ping — returns True if ClickHouse responds.""" + try: + r = httpx.get(f"{self._base_url}/ping", timeout=5.0) + return r.status_code == 200 and r.text.strip() == "Ok." + except Exception: + return False diff --git a/railway.toml b/railway.toml new file mode 100644 index 00000000..3db3078c --- /dev/null +++ b/railway.toml @@ -0,0 +1,17 @@ +# railway.toml +# Deploy ClickAdvisor MCP server to Railway with one click. +# https://railway.app/new/template — point at this repo. + +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 + +[[services]] +name = "clickadvisor-mcp" + +[services.env] +PORT = { default = "8000" }