@rantingjs/metrics
v0.1.0
Published
How a ranting app does in production: servers, usage, queues, cache, exceptions and slow requests, queries, jobs and outgoing requests
Maintainers
Readme
@rantingjs/metrics
How a ranting app is doing in production, at a glance: each server's CPU, memory and disks, the users making the most requests, slow requests, queries, jobs and outgoing requests, exceptions by class and place, queues, and cache hits.
Each process records in memory and writes at the end of each request, job and command. Every record goes to the buckets of the four periods the dashboard shows (1 hour, 6 hours, 24 hours, 7 days; 60 buckets each), so a card reads 60 rows, not every event. The raw entries are kept too, for the oldest, partial bucket of a period.
bun add @rantingjs/metrics
bunx ranting install @rantingjs/metrics --skip-add # METRICS_ENABLED in .env, src/config/metrics.ts
bunx ranting migrate # metrics_entries, metrics_aggregates, metrics_valuesThe app needs DatabaseModule. Open /metrics; the cards refresh every 5 seconds.
Who may open it
The viewMetrics ability decides. Until the app defines its own, it lets everyone in while app.env is local and
nobody elsewhere (an app without AuthModule gets that rule too). Define it where the app starts:
constructor(gate: Gatekeeper) {
gate.define('viewMetrics', (user: User) => user.isAdmin);
}It is an ordinary ability: the gate's before callbacks count, guests are refused without asking it (unless it is
defined with { guests: true }, as for a monitoring token: () => request().header('x-metrics-token') === token),
and a denial's own status is the answer (Gate.denyAsNotFound() gives 404).
Commands
bunx ranting metrics:check # samples the server's CPU, memory and disks every 15 seconds; one per server
bunx ranting metrics:check --once # one sample
bunx ranting metrics:work # with ingest.driver redis: moves the stream into the database
bunx ranting metrics:restart # running check and work processes stop after their current round
bunx ranting metrics:clear # deletes everything (--type slow_query for one type; --force in production)Old data is deleted on 1 in 1000 writes (ingest.trim.lottery), and by metrics:work every 10 minutes.
Config
| Key | Default | |
|---|---|---|
| enabled | METRICS_ENABLED, false | Record. ranting install writes METRICS_ENABLED=true to .env. |
| domain | METRICS_DOMAIN | The dashboard only on this host (404 on others). |
| path | METRICS_PATH, metrics | Where the dashboard lives. |
| storage.database | { connection: METRICS_DB_CONNECTION, chunk: 1000 } | The tables' connection; rows per insert. |
| ingest.driver | METRICS_INGEST_DRIVER, storage | storage writes directly, redis to a stream that metrics:work writes in bulk, null drops it. |
| ingest.buffer | 5000 | A process writes early once it holds this many records. |
| cache | METRICS_CACHE_DRIVER, the default | The store for the dashboard's 5-second card cache and metrics:restart. |
| recorders | see below | What is recorded. |
| dashboard | see below | The cards and their place on a 12-column grid. |
Each recorder takes enabled, sampleRate (0 to 1: record that share and scale the counts up, shown with ~) and
ignore (patterns of what not to record). Thresholds are milliseconds, one number or per pattern:
threshold: { default: 1000, patterns: [[/^\/reports/, 5000]] }.
| Recorder | Records |
|---|---|
| servers | CPU, memory and the use of directories, as serverName, from metrics:check. |
| userRequests | Requests by each signed-in user. |
| userJobs | Jobs queued by each signed-in user. |
| slowRequests | Requests over threshold, by method, route as registered (/posts/:id) and controller method; files from public/ and the dashboard are left out. |
| slowQueries | Queries over threshold, by SQL with its placeholders (never the values) and the line of the app that ran it (location); maxQueryLength cuts long SQL. |
| slowJobs | Jobs over threshold, by class. |
| slowOutgoingRequests | Http requests over threshold, by method and URL; groups turns URLs into one name. |
| exceptions | Exceptions the app reports, by class and the first line of the app's code (location). |
| queues | Jobs queued, processing, processed, released and failed, per connection:queue (not sync). |
| cacheInteractions | Hits and misses by key; groups turns keys into one name (user:42 into user:*). |
Dashboard
dashboard: [
{ card: 'servers', cols: 'full', ignoreAfter: 3600 }, // leave out a server silent for an hour
{ card: 'usage', cols: 4, rows: 2 }, // or type: 'requests' | 'slowRequests' | 'jobs'
{ card: 'queues', cols: 4 },
{ card: 'cache', cols: 4 },
{ card: 'slowQueries', cols: 8 },
{ card: 'exceptions', cols: 6 },
{ card: 'slowRequests', cols: 6 },
{ card: 'slowJobs', cols: 6 },
{ card: 'slowOutgoingRequests', cols: 6, expand: true },
{ card: 'deploys', cols: 6 }, // a card of the app's own
],The usage card shows users by name: from the auth.model config, or as the app says:
metrics.user((user) => ({ name: user.fullName, extra: user.email, avatar: user.photoUrl }));A card of the app's own records with metrics.record() (count, sum, max, min, avg) or metrics.set(), and reads
with storage into a table:
metrics.record('deploy', release, durationMs).max().count();
metrics.card('deploys', {
title: 'Deploys',
data: async ({ storage, period }) => ({
columns: ['Release', 'Slowest', 'Count'],
rows: (await storage.aggregate('deploy', ['max', 'count'], period)).map((row) => [row.key, row.max, row.count]),
}),
});