Аналитика продукта
Предиктека считает, чем её пользуются, и отправляет эти события на свой хост Porcupine — самостоятельный сервис продуктовой аналитики: принимает события, кладёт их в SQLite и проецирует в DuckLake, где к ним пускают SQL.
Внешних сервисов нет: адрес задаёт оператор установки, а если он не задан — не отправляется ничего. Установка в закрытом контуре работает ровно так же, только без событий.
Что отправляется
| Событие | Когда | Свойства |
|---|---|---|
signed_in |
успешный вход | способ входа |
template_installed |
установлен шаблон | шаблон, версия, движок, форматы, число полей |
document_printed |
напечатан документ | шаблон, версия, движок, формат, приложений, страниц, байт |
document_refused |
документ отклонён до печати | шаблон, причина (данные не по схеме, приложение не включилось) |
batch_started |
запущен пакет | шаблон, строк, приложений |
import_started |
запущено сопоставление | классификатор |
licence_installed |
поставлена лицензия | откуда |
mcp_token_issued |
выпущен ключ ассистента | — |
mcp_tool_called |
вызов инструмента ассистентом | инструмент, успех, миллисекунды, ошибка |
К каждому событию добавляются workspace, plan, service и build. |
Ассистент считается так же
Каждый вызов MCP-инструмента — событие mcp_tool_called: какой инструмент, сработал ли, сколько
занял и текст ошибки, если не сработал. Это самая полезная часть аналитики, потому что ассистент —
самый активный пользователь продукта, а его отказ («шаблона нет», «данные не по схеме») — это
готовый список того, что людям мешает.
Пишется вызов, а не его содержимое: имя инструмента, workspace, тариф, длительность. Данные документа — тайна клиента; то, что ассистент напечатал договор, — метрика. По той же причине журнал действий не хранит тела документов.
Актор — хеш токена: один ассистент считается одним актором, два разных различаются, сам токен никуда не уходит.
Чего не отправляется
Содержимого документов, имён файлов, адресов почты, названий контор и workspace. Человек
превращается в псевдоним — хеш его идентификатора записи (u_9f2c…), workspace — в свой
случайный идентификатор. Печатать договор — рабочая тайна; то, что его напечатали, — метрика.
Где смотреть
Кабинет: страница /analytics — доступна владельцу workspace, потому что показывает цифры всей
установки. Там же таблица событий, поле SQL и четыре готовых вопроса: печати по дням, какие шаблоны,
вызовы ассистента, ошибки ассистента. Результат запроса — таблица, столбцы в том порядке, в котором
сервер их вернул.
Чтение настраивается отдельно от записи: ключ записи умеет только добавлять события, а странице
нужен сервисный ключ с правом events:read.
export PORCUPINE_PROJECT=d3346824-… # id проекта
export PORCUPINE_API_TOKEN=pc_sk_… # сервисный ключ, право events:read
Кнопка «Проверить связь» отправляет пустой пакет и отвечает на первый вопрос оператора:
неверный ключ или недоступный хост. Счётчики те же, что отдаёт GET /api/analytics в JSON —
этого достаточно для мониторинга.
Как включить
export PORCUPINE_URL=http://analytics.example.com
export PORCUPINE_WRITE_KEY=pk_... # публичный ключ записи проекта
Перезапустите кабинет. Проверить, что связь есть: GET /api/analytics отвечает счётчиками
отправленных, отброшенных и неудачных событий и последней ошибкой — по ним видно разницу между
«никто не пользуется», «не настроено» и «хост отказывает».
Почему это не может сломать печать
Клиент internal/analytics отправляет события в фоне: у него ограниченная очередь, и при
переполнении событие отбрасывается и считается, а не ждёт. Ошибка аналитики не влияет на печать
документа, установку шаблона или запуск прогона — это проверено тестом
TestUnreachableHostNeverBlocks.
Неудачи делятся на два вида: 429 и 5xx повторяются с тем же message_id (дедупликация на
стороне Porcupine, поэтому повтор не превращается в двойной счёт), а 4xx не повторяется вовсе —
неправильное событие не станет правильным от второй попытки.
Запросы
События лежат в таблице events проекта. Число напечатанных документов по дням:
SELECT CAST(event_time AS DATE) AS day, count(*) AS printed
FROM events
WHERE event = 'document_printed'
GROUP BY day ORDER BY day
Какие шаблоны печатают, по убыванию:
SELECT json_extract_string(properties, '$.template') AS template,
count(*) AS prints,
sum(CAST(json_extract_string(properties, '$.pages') AS INTEGER)) AS pages
FROM events
WHERE event = 'document_printed'
GROUP BY template ORDER BY prints DESC
Доля документов, напечатанных вместе с приложениями:
SELECT count(*) FILTER (WHERE CAST(json_extract_string(properties, '$.appendices') AS INTEGER) > 0) * 100.0
/ count(*) AS with_appendix_percent
FROM events
WHERE event = 'document_printed'
Воронка: вошли → установили шаблон → напечатали:
SELECT
count(DISTINCT person_id) FILTER (WHERE event = 'signed_in') AS signed_in,
count(DISTINCT person_id) FILTER (WHERE event = 'template_installed') AS installed,
count(DISTINCT person_id) FILTER (WHERE event = 'document_printed') AS printed
FROM events
WHERE event_time > now() - INTERVAL 30 DAY
Свой экземпляр для разработки
git clone https://github.com/olup/porcupine && cd porcupine
# README → quickstart: создаёт .env со случайным bootstrap-секретом
docker compose -f compose.yaml -f compose.oauth.yaml -f compose.release.yaml up -d
curl --fail --silent --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8080/readyz
Экземпляр поднимается на http://localhost:8080. Проект и ключ записи выдаются в его интерфейсе;
дальше PORCUPINE_URL и PORCUPINE_WRITE_KEY выше — и события из кабинета поедут туда.
У Porcupine нет своих экранов: это API, MCP и CLI. Поэтому админка живёт в кабинете (страница
/analytics), а не в отдельном приложении.
Внутренний домен
Если установка опубликована в tailnet, Porcupine удобно держать там же — наружу он не смотрит:
tailscale serve --bg --https=8443 18080
tailscale serve status
# https://<host>.<tailnet>.ts.net:8443 -> http://127.0.0.1:18080
Порт 8443, а не 443: 443 на сервере обычно занят обратным прокси, и tailscale serve в этом случае
падает с bind: address already in use. PORCUPINE_OAUTH_ISSUER должен совпадать с этим адресом
символ в символ, иначе OAuth-клиенты получат токен не для того ресурса.