b9f3de80f5
The metrics INFRA is already deployed (VictoriaMetrics scraping docmost:9464,
Grafana dashboards, alerts) with a target `gitmost-app` that is red because the
app half didn't exist. This is that half. The contract (metric names, port,
table, endpoint) is FIXED by the deployed infra and matched exactly.
Server (prom-client):
- A bare node:http `/metrics` server on METRICS_PORT (default 9464), SEPARATE
from the Fastify :3000 listener so /metrics never exists publicly; the whole
subsystem is OFF when METRICS_PORT is unset.
- collectDefaultMetrics() + http_request_duration_seconds{method,route,status}
via a Fastify onResponse hook using the ROUTE TEMPLATE (req.routeOptions.url,
never the raw URL — bounded cardinality; 404 -> "unknown"), EXCLUDING SSE/
streaming responses (would record the connection lifetime and poison p95).
- db_query_duration_seconds (Kysely log callback, labelled by the leading SQL
token), bullmq_queue_depth{queue} (getJobCounts every 15s) +
bullmq_job_duration_seconds{queue} (worker completed/failed),
collab_store_duration_seconds (around onStoreDocument).
- POST /api/telemetry/vitals — PUBLIC (sendBeacon) but IP-throttled; ~16KB body
cap, <=50 events/batch, metric-name + rating whitelist, attr truncated to 120
chars, batch insert; malformed/foreign/oversized silently dropped and 200'd (no
browser retry). New migration `client_metrics` (schema byte-identical to the
contract, both indexes, conditional grafana_ro GRANT; no app-side retention —
the maintenance container prunes >90d).
Client (web-vitals):
- initVitals() decides sampling ONCE per session (25%, sessionStorage) BEFORE
subscribing; onINP/onLCP/onCLS/onTTFB (attribution) buffered + flushed via
navigator.sendBeacon on visibilitychange:hidden and a timer (not fetch-per-
metric). Custom: editor_tx_ms (dispatchTransaction sync-part timer, >8ms, with
doc_size), page_open_ms, longtask_ms. Route labels are templates only; no
titles/slugs/text.
Gate: server + client tsc 0, frozen install 0 (added prom-client + web-vitals +
regenerated the lock), server metrics/vitals tests 11, client route-template 5,
and the migration verified valid against real Postgres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
2.2 KiB
TypeScript
71 lines
2.2 KiB
TypeScript
/**
|
|
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
|
*
|
|
* Perf metrics must be labelled by route template only — never a raw path with
|
|
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
|
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
|
* does not match a known pattern collapses to `other`.
|
|
*
|
|
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
|
*/
|
|
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
|
// Share pages (public).
|
|
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
|
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
|
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
|
// Page redirect.
|
|
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
|
// Space + page.
|
|
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
|
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
|
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
|
// Misc dynamic.
|
|
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
|
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
|
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
|
];
|
|
|
|
// Static routes we accept verbatim (finite set).
|
|
const STATIC_ROUTES = new Set<string>([
|
|
'/home',
|
|
'/spaces',
|
|
'/favorites',
|
|
'/login',
|
|
'/forgot-password',
|
|
'/password-reset',
|
|
'/setup/register',
|
|
'/settings/account/profile',
|
|
'/settings/account/preferences',
|
|
'/settings/workspace',
|
|
'/settings/ai',
|
|
'/settings/members',
|
|
'/settings/groups',
|
|
'/settings/spaces',
|
|
'/settings/sharing',
|
|
]);
|
|
|
|
export function templateRoute(pathname: string): string {
|
|
// Normalise a trailing slash (except root).
|
|
const path =
|
|
pathname.length > 1 && pathname.endsWith('/')
|
|
? pathname.slice(0, -1)
|
|
: pathname;
|
|
|
|
if (path === '' || path === '/') return '/';
|
|
if (STATIC_ROUTES.has(path)) return path;
|
|
|
|
for (const { re, template } of ROUTE_PATTERNS) {
|
|
if (re.test(path)) return template;
|
|
}
|
|
return 'other';
|
|
}
|
|
|
|
/** Template for the current window location. */
|
|
export function currentRouteTemplate(): string {
|
|
try {
|
|
return templateRoute(window.location.pathname);
|
|
} catch {
|
|
return 'other';
|
|
}
|
|
}
|