[observability] Дев-часть перф-метрик: /metrics на :9464 (prom-client), POST /api/telemetry/vitals + таблица client_metrics, кастомные метрики редактора #355

Closed
opened 2026-07-04 22:30:13 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Инфраструктурная половина метрик уже развёрнута (стек gitmost на island: VictoriaMetrics + Grafana + postgres/redis-экспортеры, Traefik-метрики, дашборд «gitmost perf» на 9 панелей, 6 алертов в Telegram). Скрейпер уже ходит на docmost:9464 — таргет gitmost-app красный, потому что дев-части не существует. Панели INP/латентности печати/открытия страницы пустые, потому что нет таблицы client_metrics и телеметрии клиента.

Эта ишью — недостающая половина. Контракт зафиксирован развёрнутой инфраструктурой и менять его дороже, чем соблюсти:

Параметр Значение (закреплено деплоем)
Порт метрик METRICS_PORT=9464, отдельный от :3000, не публикуется
Имена метрик http_request_duration_seconds{method,route,status}, db_query_duration_seconds, bullmq_queue_depth{queue}, bullmq_job_duration_seconds{queue}
Таблица виталов ровно client_metrics (maintenance-контейнер делает суточный GRANT SELECT … TO grafana_ro и DELETE старше 90 дней по created_at — ретенцию в приложении писать НЕ нужно)
Эндпоинт виталов POST /api/telemetry/vitals

Зачем: без метрик эффект перф-комплекта (#340, #342–#344, #346, #348) недоказуем, а регрессии невидимы. Это базовая линия «до» для всех этих работ.

Границы изменения

apps/server (prom-client, второй HTTP-листенер, Kysely-хук, BullMQ-инструментация, telemetry-контроллер, одна миграция) + apps/client (web-vitals, кастомные метрики, батч-отправка). Поведение фич 1:1. Одна новая зависимость на сервере (prom-client), одна на клиенте (web-vitals).

Решение

1. Сервер: prom-client на отдельном порту

  • collectDefaultMetrics() (event loop lag, GC, память — дашборд уже ссылается на nodejs_eventloop_lag_p99_seconds).
  • Гистограмма HTTP в Fastify onResponse-хуке:
// Route TEMPLATE (req.routeOptions.url → "/pages/:id"), never the raw URL —
// bounded label cardinality is a hard requirement
httpHist.observe(
  { method: req.method, route: req.routeOptions?.url ?? "unknown", status: reply.statusCode },
  reply.elapsedTime / 1000,
);
  • Отдельный листенер: включается только при заданном METRICS_PORT; поднять тривиальный node:http-сервер, отдающий register.metrics() — метрики не существуют на основном порту вообще (Traefik-блок /metrics на публичном роутере уже стоит как страховка).
  • Kysely: колбэк log в database.module.tsdb_query_duration_seconds (у события есть queryDurationMillis; лейбл — первый токен SQL: select/insert/update/delete, НЕ полный запрос).
  • BullMQ: gauge bullmq_queue_depth{queue} (опрос getJobCounts() раз в 15 с) + bullmq_job_duration_seconds{queue} из worker-событий completed/failed. Сразу покажет эффект дедупа из #348.
  • Collab: таймер вокруг onStoreDocument в persistence.extension.tscollab_store_duration_seconds (панели пока нет — добавится, имя согласовано).

2. Сервер: приём виталов + миграция

POST /api/telemetry/vitals: публичный (шлют браузеры), но @Throttle, лимит тела ~16 КБ, максимум 50 событий в батче, whitelist имён метрик, insert одним батчем. Миграция:

CREATE TABLE client_metrics (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now(),
  name text NOT NULL,          -- INP | LCP | CLS | TTFB | editor_tx_ms | page_open_ms | longtask_ms
  value double precision NOT NULL,
  rating text,                 -- good | needs-improvement | poor (web-vitals only)
  route text,                  -- templated: /s/:space/p/:slug — never raw slugs
  attr text,                   -- attribution target, truncated to 120 chars
  doc_size int,                -- editor_tx_ms only
  workspace_id uuid
);
CREATE INDEX idx_client_metrics_name_created ON client_metrics (name, created_at);
CREATE INDEX idx_client_metrics_created ON client_metrics (created_at);  -- retention DELETE uses this

-- Grafana reads via grafana_ro (role created by DevOps); grant immediately if it
-- exists so dashboards work without waiting for the daily maintenance re-grant
DO $$ BEGIN
  IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'grafana_ro') THEN
    GRANT SELECT ON client_metrics TO grafana_ro;
  END IF;
END $$;

3. Клиент: web-vitals + кастомные метрики

  • web-vitals (attribution-сборка): onINP/onLCP/onCLS/onTTFB → буфер → navigator.sendBeacon на visibilitychange:hidden и по таймеру.
  • Семплирование: решение раз на сессию (Math.random() < 0.25 → sessionStorage-флаг); роуты — только темплейты (/s/:space/p/:slug), attr — селектор из attribution, обрезанный до 120 символов. Никаких заголовков страниц, слагов, текста.
  • editor_tx_ms: обёртка dispatchTransaction в page-editor — время синхронной части транзакции, слать только > 8 мс, с doc_size. Это прямая метрика для #343.
  • page_open_ms: performance.mark на клике по строке дерева/ссылке → performance.measure при первом рендере контента редактора. Метрика для local-first направления.
  • longtask_ms: PerformanceObserver({type:"longtask"}), агрегированная сумма за окно.

Крайние случаи

  • Кардинальность лейблов — жёсткое требование: route только из темплейтов (на 404-путях Fastify может не дать routeOptions.url — схлопывать в unknown), queue-имена — конечный список, никаких id в лейблах.
  • Телеметрия не должна вредить перфу, который измеряет: буфер+sendBeacon (не fetch на каждую метрику), observers пассивные, семплирование до подписки на события.
  • Приватность: whitelist полей на сервере, отбрасывать всё лишнее из батча молча (200, не 400 — не давать браузеру ретраить).
  • SSE AI-чата: onResponse-хук на стриминге даст длительность соединения, а не ответа — исключить stream-роуты из гистограммы (лейбл или skip), иначе p95 «испортится» долгоживущими соединениями.
  • METRICS_PORT не задан (дефолт для чужих self-hosted) — метрики полностью выключены, ни листенера, ни коллекторов.
  • Grafana grant: миграция грантует при существовании роли (см. SQL); если роли нет — maintenance-контейнер догрантует в течение суток, оба пути без ручных шагов.
  • Часовые пояса: created_at timestamptz, панели считают в UTC — совпадает с VM.

Тесты / проверка

  • Юнит: telemetry-контроллер — валидный батч → insert; чужие имена/поля → отброшены; oversized → 413/обрезка.
  • Юнит: route-темплейт в лейблах (не raw URL) на параметризованном роуте.
  • Интеграция: curl docmost:9464/metrics → все 4 семейства метрик присутствуют; на :3000 /metrics → 404.
  • После деплоя: таргет gitmost-app в VM зеленеет; панели INP/editor_tx/page_open наполняются при семплированной сессии; SELECT count(*) FROM client_metrics растёт, старше 90 дней — чистится (проверка через maintenance-лог).
  • Смок перфа самой телеметрии: с включённым семплированием INP страницы не деградирует (сравнить p75 с выключенным — разница в шуме).

Вне скоупа

  • Инфраструктура (VM/Grafana/алерты/ретенция/Traefik) — развёрнута, не трогаем.
  • Дашборд-панель для collab_store_duration_seconds — попросить DevOps добавить после выката.
  • Lighthouse CI / бюджет бандла в CI — отдельная задача, логично вместе с мержем #342.
  • Серверный OpenTelemetry-трейсинг — не сейчас; гистограмм достаточно для текущих целей.

План работ

  1. prom-client + листенер :9464 + HTTP-гистограмма + default-метрики.
  2. Kysely-хук + BullMQ-инструментация + collab-таймер.
  3. Миграция client_metrics + telemetry-контроллер с лимитами.
  4. Клиент: web-vitals + editor_tx_ms + page_open_ms + longtask, семплирование, sendBeacon.
  5. Прогон приёмки с DevOps (зелёный таргет, наполнение панелей) — фиксация базовой линии «до» для #342/#343/#348.
# Суть Инфраструктурная половина метрик **уже развёрнута** (стек gitmost на island: VictoriaMetrics + Grafana + postgres/redis-экспортеры, Traefik-метрики, дашборд «gitmost perf» на 9 панелей, 6 алертов в Telegram). Скрейпер уже ходит на `docmost:9464` — таргет `gitmost-app` красный, потому что дев-части не существует. Панели INP/латентности печати/открытия страницы пустые, потому что нет таблицы `client_metrics` и телеметрии клиента. Эта ишью — недостающая половина. **Контракт зафиксирован развёрнутой инфраструктурой и менять его дороже, чем соблюсти:** | Параметр | Значение (закреплено деплоем) | |---|---| | Порт метрик | `METRICS_PORT=9464`, отдельный от :3000, не публикуется | | Имена метрик | `http_request_duration_seconds{method,route,status}`, `db_query_duration_seconds`, `bullmq_queue_depth{queue}`, `bullmq_job_duration_seconds{queue}` | | Таблица виталов | ровно `client_metrics` (maintenance-контейнер делает суточный `GRANT SELECT … TO grafana_ro` и `DELETE` старше 90 дней по `created_at` — ретенцию в приложении писать НЕ нужно) | | Эндпоинт виталов | `POST /api/telemetry/vitals` | Зачем: без метрик эффект перф-комплекта (#340, #342–#344, #346, #348) недоказуем, а регрессии невидимы. Это базовая линия «до» для всех этих работ. # Границы изменения `apps/server` (prom-client, второй HTTP-листенер, Kysely-хук, BullMQ-инструментация, telemetry-контроллер, одна миграция) + `apps/client` (web-vitals, кастомные метрики, батч-отправка). Поведение фич 1:1. Одна новая зависимость на сервере (`prom-client`), одна на клиенте (`web-vitals`). # Решение ## 1. Сервер: prom-client на отдельном порту - `collectDefaultMetrics()` (event loop lag, GC, память — дашборд уже ссылается на `nodejs_eventloop_lag_p99_seconds`). - Гистограмма HTTP в Fastify `onResponse`-хуке: ```ts // Route TEMPLATE (req.routeOptions.url → "/pages/:id"), never the raw URL — // bounded label cardinality is a hard requirement httpHist.observe( { method: req.method, route: req.routeOptions?.url ?? "unknown", status: reply.statusCode }, reply.elapsedTime / 1000, ); ``` - Отдельный листенер: включается только при заданном `METRICS_PORT`; поднять тривиальный `node:http`-сервер, отдающий `register.metrics()` — метрики не существуют на основном порту вообще (Traefik-блок `/metrics` на публичном роутере уже стоит как страховка). - **Kysely**: колбэк `log` в [database.module.ts](apps/server/src/database/database.module.ts) → `db_query_duration_seconds` (у события есть `queryDurationMillis`; лейбл — первый токен SQL: select/insert/update/delete, НЕ полный запрос). - **BullMQ**: gauge `bullmq_queue_depth{queue}` (опрос `getJobCounts()` раз в 15 с) + `bullmq_job_duration_seconds{queue}` из worker-событий `completed`/`failed`. Сразу покажет эффект дедупа из #348. - **Collab**: таймер вокруг `onStoreDocument` в [persistence.extension.ts](apps/server/src/collaboration/extensions/persistence.extension.ts) → `collab_store_duration_seconds` (панели пока нет — добавится, имя согласовано). ## 2. Сервер: приём виталов + миграция `POST /api/telemetry/vitals`: публичный (шлют браузеры), но `@Throttle`, лимит тела ~16 КБ, максимум 50 событий в батче, whitelist имён метрик, insert одним батчем. Миграция: ```sql CREATE TABLE client_metrics ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now(), name text NOT NULL, -- INP | LCP | CLS | TTFB | editor_tx_ms | page_open_ms | longtask_ms value double precision NOT NULL, rating text, -- good | needs-improvement | poor (web-vitals only) route text, -- templated: /s/:space/p/:slug — never raw slugs attr text, -- attribution target, truncated to 120 chars doc_size int, -- editor_tx_ms only workspace_id uuid ); CREATE INDEX idx_client_metrics_name_created ON client_metrics (name, created_at); CREATE INDEX idx_client_metrics_created ON client_metrics (created_at); -- retention DELETE uses this -- Grafana reads via grafana_ro (role created by DevOps); grant immediately if it -- exists so dashboards work without waiting for the daily maintenance re-grant DO $$ BEGIN IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'grafana_ro') THEN GRANT SELECT ON client_metrics TO grafana_ro; END IF; END $$; ``` ## 3. Клиент: web-vitals + кастомные метрики - `web-vitals` (attribution-сборка): `onINP/onLCP/onCLS/onTTFB` → буфер → `navigator.sendBeacon` на `visibilitychange:hidden` и по таймеру. - **Семплирование**: решение раз на сессию (`Math.random() < 0.25` → sessionStorage-флаг); роуты — только темплейты (`/s/:space/p/:slug`), attr — селектор из attribution, обрезанный до 120 символов. Никаких заголовков страниц, слагов, текста. - **`editor_tx_ms`**: обёртка `dispatchTransaction` в page-editor — время синхронной части транзакции, слать только > 8 мс, с `doc_size`. Это прямая метрика для #343. - **`page_open_ms`**: `performance.mark` на клике по строке дерева/ссылке → `performance.measure` при первом рендере контента редактора. Метрика для local-first направления. - **`longtask_ms`**: `PerformanceObserver({type:"longtask"})`, агрегированная сумма за окно. # Крайние случаи - **Кардинальность лейблов** — жёсткое требование: route только из темплейтов (на 404-путях Fastify может не дать `routeOptions.url` — схлопывать в `unknown`), queue-имена — конечный список, никаких id в лейблах. - **Телеметрия не должна вредить перфу, который измеряет**: буфер+sendBeacon (не fetch на каждую метрику), observers пассивные, семплирование до подписки на события. - **Приватность**: whitelist полей на сервере, отбрасывать всё лишнее из батча молча (200, не 400 — не давать браузеру ретраить). - **SSE AI-чата**: `onResponse`-хук на стриминге даст длительность соединения, а не ответа — исключить stream-роуты из гистограммы (лейбл или skip), иначе p95 «испортится» долгоживущими соединениями. - **`METRICS_PORT` не задан** (дефолт для чужих self-hosted) — метрики полностью выключены, ни листенера, ни коллекторов. - **Grafana grant**: миграция грантует при существовании роли (см. SQL); если роли нет — maintenance-контейнер догрантует в течение суток, оба пути без ручных шагов. - **Часовые пояса**: `created_at timestamptz`, панели считают в UTC — совпадает с VM. # Тесты / проверка - Юнит: telemetry-контроллер — валидный батч → insert; чужие имена/поля → отброшены; oversized → 413/обрезка. - Юнит: route-темплейт в лейблах (не raw URL) на параметризованном роуте. - Интеграция: `curl docmost:9464/metrics` → все 4 семейства метрик присутствуют; на :3000 `/metrics` → 404. - После деплоя: таргет `gitmost-app` в VM зеленеет; панели INP/editor_tx/page_open наполняются при семплированной сессии; `SELECT count(*) FROM client_metrics` растёт, старше 90 дней — чистится (проверка через maintenance-лог). - Смок перфа самой телеметрии: с включённым семплированием INP страницы не деградирует (сравнить p75 с выключенным — разница в шуме). # Вне скоупа - Инфраструктура (VM/Grafana/алерты/ретенция/Traefik) — развёрнута, не трогаем. - Дашборд-панель для `collab_store_duration_seconds` — попросить DevOps добавить после выката. - Lighthouse CI / бюджет бандла в CI — отдельная задача, логично вместе с мержем #342. - Серверный OpenTelemetry-трейсинг — не сейчас; гистограмм достаточно для текущих целей. # План работ 1. prom-client + листенер :9464 + HTTP-гистограмма + default-метрики. 2. Kysely-хук + BullMQ-инструментация + collab-таймер. 3. Миграция `client_metrics` + telemetry-контроллер с лимитами. 4. Клиент: web-vitals + editor_tx_ms + page_open_ms + longtask, семплирование, sendBeacon. 5. Прогон приёмки с DevOps (зелёный таргет, наполнение панелей) — фиксация базовой линии «до» для #342/#343/#348.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#355