Kommo + Grafana: дашборд продаж на основе данных CRM без вендор-локина

Kommo + Grafana: дашборд продаж на основе данных CRM без вендор-локина

Grafana — open-source платформа визуализации данных: 50+ datasource plugins (Postgres, InfluxDB, Prometheus, Elasticsearch, MySQL), гибкие дашборды, алерты. Широко используется для мониторинга инфраструктуры, но отлично подходит для business-аналитики когда данные CRM выгружаются в SQL-базу. В отличие от Metabase или Redash, Grafana — инструмент для команд где уже есть Postgres и нет желания платить за BI SaaS. Нет вендорного lock-in, нет лицензий — только ваши данные и SQL.

Grafana vs Metabase vs Redash для CRM-аналитики

ПараметрGrafanaMetabaseRedash
Open-sourceДаДа (Community)Да
Time-series фокусДаНетНет
Datasources50+ (Postgres, InfluxDB, Prometheus…)SQL-базы, MongoDBSQL-базы
Self-hostedДаДаДа
АлертыНативные, multi-channelЧерез SlackОграниченно
Grafana CloudЕстьНетНет
Подходит дляDevOps + business метрики в одномБизнес-аналитикаSQL-дашборды

Grafana выбирают команды где уже есть DevOps-дашборды в Grafana и хотят добавить business-метрики в тот же инструмент.

Архитектура: Kommo -> ETL -> Postgres -> Grafana

Прямого Grafana datasource для Kommo нет. Правильная архитектура:

Kommo REST API  ->  Python ETL (cron)  ->  Postgres  ->  Grafana
  1. Python ETL (cron каждые 30–60 мин): вытягивает данные из Kommo API (сделки, контакты, статусы)
  2. Postgres (или TimescaleDB для time-series): хранит нормализованные данные
  3. Grafana: PostgreSQL datasource -> SQL-запросы -> панели и дашборды

ETL: выгрузка данных Kommo в Postgres

import requests
import psycopg2
from datetime import datetime, timezone, timedelta

KOMMO_SUBDOMAIN = "youraccount"
KOMMO_TOKEN     = "your_access_token"
KOMMO_BASE_URL  = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HEADERS   = {"Authorization": f"Bearer {KOMMO_TOKEN}"}

DB_DSN = "postgresql://user:pass@localhost:5432/analytics"

def fetch_leads(updated_after: datetime) -> list:
    # Paginated fetch всех сделок обновлённых после даты
    ts    = int(updated_after.timestamp())
    leads = []
    page  = 1
    while True:
        resp = requests.get(
            f"{KOMMO_BASE_URL}/leads",
            headers=KOMMO_HEADERS,
            params={
                "updated_at[from]": ts,
                "page": page,
                "limit": 250,
            },
        )
        if resp.status_code == 204:
            break
        resp.raise_for_status()
        data = resp.json().get("_embedded", {}).get("leads", [])
        if not data:
            break
        leads.extend(data)
        page += 1
    return leads

def upsert_leads_to_postgres(leads: list):
    conn = psycopg2.connect(DB_DSN)
    cur  = conn.cursor()
    create_sql = (
        "CREATE TABLE IF NOT EXISTS kommo_leads ("
        "id BIGINT PRIMARY KEY, name TEXT, status_id INT, "
        "pipeline_id INT, responsible_user_id BIGINT, price NUMERIC, "
        "created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, "
        "closed_at TIMESTAMPTZ, loss_reason_id INT)"
    )
    cur.execute(create_sql)
    for lead in leads:
        insert_sql = (
            "INSERT INTO kommo_leads "
            "(id, name, status_id, pipeline_id, responsible_user_id, "
            "price, created_at, updated_at, closed_at, loss_reason_id) "
            "VALUES (%s,%s,%s,%s,%s,%s,"
            "to_timestamp(%s),to_timestamp(%s),to_timestamp(%s),%s) "
            "ON CONFLICT (id) DO UPDATE SET "
            "status_id=EXCLUDED.status_id, price=EXCLUDED.price, "
            "updated_at=EXCLUDED.updated_at, closed_at=EXCLUDED.closed_at, "
            "loss_reason_id=EXCLUDED.loss_reason_id"
        )
        cur.execute(insert_sql, (
            lead["id"], lead.get("name"),
            lead.get("status_id"), lead.get("pipeline_id"),
            lead.get("responsible_user_id"), lead.get("price"),
            lead.get("created_at"), lead.get("updated_at"),
            lead.get("closed_at"), lead.get("loss_reason_id"),
        ))
    conn.commit()
    cur.close(); conn.close()

def run_etl():
    updated_after = datetime.now(timezone.utc) - timedelta(hours=2)
    leads = fetch_leads(updated_after)
    if leads:
        upsert_leads_to_postgres(leads)
        print(f"ETL: {len(leads)} leads upserted")

if __name__ == "__main__":
    run_etl()

SQL-запросы для Grafana панелей

После настройки Postgres datasource в Grafana — строите панели через SQL.

Воронка конверсии (Bar Chart):

SELECT
    s.name AS stage,
    COUNT(*) AS leads,
    SUM(l.price) AS pipeline_value
FROM kommo_leads l
JOIN kommo_statuses s ON l.status_id = s.id
WHERE l.pipeline_id = $pipeline_id
  AND l.created_at >= $__timeFrom()
GROUP BY s.name, s.sort
ORDER BY s.sort;

Lead Velocity (Leads created per week):

SELECT
    date_trunc('week', created_at) AS week,
    COUNT(*) AS new_leads
FROM kommo_leads
WHERE created_at >= $__timeFrom()
  AND created_at <= $__timeTo()
GROUP BY 1
ORDER BY 1;

Сравнение менеджеров (Won deals):

SELECT
    u.name AS manager,
    COUNT(*) AS won_deals,
    SUM(l.price) AS total_revenue,
    AVG(EXTRACT(EPOCH FROM (l.closed_at - l.created_at))/86400) AS avg_days_to_close
FROM kommo_leads l
JOIN kommo_users u ON l.responsible_user_id = u.id
WHERE l.status_id = $won_status_id
  AND l.closed_at >= $__timeFrom()
GROUP BY u.name
ORDER BY total_revenue DESC;

Win Rate по месяцам:

SELECT
    date_trunc('month', closed_at) AS month,
    COUNT(*) FILTER (WHERE status_id = $won_status_id) AS won,
    COUNT(*) FILTER (WHERE status_id = $lost_status_id) AS lost,
    ROUND(
        100.0 * COUNT(*) FILTER (WHERE status_id = $won_status_id)
        / NULLIF(COUNT(*), 0), 1
    ) AS win_rate_pct
FROM kommo_leads
WHERE closed_at >= $__timeFrom()
GROUP BY 1
ORDER BY 1;

Алерты: Grafana -> уведомление команде

Grafana Alert Rules позволяют: если new_leads_today = 0 -> Slack-уведомление. Если avg_deal_age > 30 дней -> email team lead.

Настройка: Grafana -> Alerting -> Alert Rules -> создать правило на основе любого SQL-запроса.

Реальный кейс

SaaS-стартап (EU, 30 человек, Kommo + Grafana + Postgres):

  • До: аналитика продаж — Excel раз в неделю. Conversion rate, lead velocity — вручную. DevOps уже использовали Grafana для infrastructure-мониторинга.
  • После: добавили бизнес-метрики в тот же Grafana. Python ETL каждый час -> Postgres -> Grafana. Один дашборд с инфра и продажами на одном экране. Нет нового инструмента — только datasource добавлен.
  • Ключевой инсайт: lead velocity упала на 40% в одну неделю -> Grafana alert -> провели ретроспективу. Оказалось: отключили один лидогенерирующий канал. Обнаружили через 2 дня, а не через 2 недели.

Для кого актуально

  • Технические команды с Grafana для DevOps — проще добавить бизнес-метрики чем внедрять новый BI-инструмент
  • Стартапы с Postgres в стеке — нет дополнительных расходов на BI SaaS
  • Компании с требованиями к self-hosted аналитике (финансы, healthcare)
  • Команды где аналитика нужна на уровне custom SQL без BI-ограничений

Часто задаваемые вопросы

Grafana Cloud vs self-hosted — что выбрать для CRM-аналитики?

Grafana Cloud Free: 3 пользователя, 10k metrics, достаточно для small team. Self-hosted: полный контроль, нет ограничений, нужен сервер (Docker или Kubernetes). Для CRM-аналитики небольшой команды — Grafana Cloud Free + Postgres на том же сервере что и ETL.

Kommo API rate limits — как не превысить при ETL?

Kommo: 7 запросов в секунду, 5000 запросов в час. При incremental ETL (только обновлённые за последние N часов) — обычно 1–5 запросов на запуск. При full sync (все сделки) — пагинация по 250, с задержкой 0.2 сек между запросами. Full sync нужен только при первом запуске.

Grafana переменные (Variables) — как параметризовать дашборд?

Grafana -> Dashboard Settings -> Variables. Тип Query: SQL-запрос из Postgres. Например, SELECT DISTINCT pipeline_id FROM kommo_leads -> dropdown в дашборде. Используется как $pipeline_id в SQL-запросах панелей. Это позволяет переключать воронку/менеджера без редактирования SQL.

Как добавить custom fields Kommo в Postgres?

Custom fields в Kommo хранятся в _embedded.custom_fields_values в виде массива. При ETL: распарсить массив по field_id -> записать в отдельные столбцы таблицы или в JSONB-колонку. JSONB позволяет делать запросы: WHERE custom_fields->>'plan' = 'growth'.

Итого

  • Архитектура: Kommo API -> Python ETL (cron) -> Postgres -> Grafana PostgreSQL datasource
  • ETL: incremental по updated_at[from], upsert по id, пагинация 250 лидов на запрос
  • Ключевые панели: воронка, lead velocity, win rate, сравнение менеджеров — всё через SQL
  • Grafana Variables: параметризовать по pipeline_id, менеджеру, периоду
  • Алерты: Lead velocity = 0, avg deal age > порог — Slack/email уведомление

Если вы используете Kommo и хотите построить дашборд в Grafana — опишите какие метрики критичны и есть ли уже Postgres в инфраструктуре. Exceltic.dev настроит ETL и базовый набор панелей.

Ещё статьи

Все →