system-tracker 3.0.12 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1152 +1,75 @@
1
1
  # system-tracker
2
2
 
3
- Count every request your Node.js app handles in Redis, per second, minute, hour, day, month and year. That covers API calls, page views, GraphQL, JSON-RPC, WebSockets, Socket.IO, server-sent events and gRPC, plus outgoing calls if you turn them on.
3
+ See how your Node.js app is used. Every page people open, every API call, and everything else your app answers is counted as it happens, then added up by the minute, hour, day, month and year. You can also count the calls your app makes to other services. A built-in dashboard shows it all as charts.
4
4
 
5
- You call it once at startup. Your routes need no tracking code.
5
+ Add one line when your app starts. You don't change your routes or write any tracking code. All it needs is a Redis database to keep the numbers in.
6
6
 
7
- ## AI Prompt
8
-
9
- Using an AI coding assistant (Claude Code, Cursor, Copilot)? Paste this into it. It looks through your project (or each project, when the folder holds several), asks in plain words what you want to track (website pages, API calls, WebSockets, calls to outside services and more) with a recommended answer for each, then sets up system-tracker, server and browser parts included:
10
-
11
- ```
12
- Read and strictly follow the SKILL.md file to implement the npm package "system-tracker".
13
-
14
- References:
15
- NPM: https://www.npmjs.com/package/system-tracker
16
- SKILL.md: https://github.com/NPM-public-repo-Jaykumar/system-tracker/blob/main/SKILL.md
17
- ```
18
-
19
- ## Contents
20
-
21
- - **[Getting started](#getting-started):** [Install](#install) · [Quick start](#quick-start) · [Set up your framework](#set-up-your-framework) · [Page views in single-page apps](#page-views-in-single-page-apps) · [Dashboard](#dashboard) · [Read the counts](#read-the-counts) · [Connect to Redis](#connect-to-redis)
22
- - **[Settings](#settings):** [Expiry](#expiry) · [Skip requests](#skip-requests) · [Choose what to track](#choose-what-to-track) · [Custom types](#custom-types) · [One URL, many operations](#one-url-many-operations) · [Name requests yourself](#name-requests-yourself) · [Limits](#limits) · [All options](#all-options)
23
- - **[How it works](#how-it-works):** [Redis keys](#redis-keys) · [What gets counted](#what-gets-counted) · [How routes are named](#how-routes-are-named) · [How it behaves](#how-it-behaves)
24
- - **[More](#more):** [Examples](#examples) · [TypeScript](#typescript) · [Demo](#demo)
25
-
26
- ## Getting started
27
-
28
- ### Install
29
-
30
- npm:
31
-
32
- ```bash
33
- npm install system-tracker
34
- ```
35
-
36
- yarn:
37
-
38
- ```bash
39
- yarn add system-tracker
40
- ```
41
-
42
- If npm stops with `ERESOLVE could not resolve`, the conflict is between other packages already in your project (for example, a package that only supports React 18 in a React 19 app). system-tracker has no peer dependencies. Install it with:
43
-
44
- ```bash
45
- npm install system-tracker --legacy-peer-deps
46
- ```
47
-
48
- ### Quick start
49
-
50
- Add this at the top of your entry file, before your server starts:
51
-
52
- ```ts
53
- import { statis } from "system-tracker";
54
-
55
- statis.init({
56
- redis: { host: "127.0.0.1", port: 6379 },
57
- prefix: "demo",
58
- });
59
- ```
60
-
61
- Every request is now counted:
62
-
63
- ```text
64
- GET /api/users/42 → demo:API:GET:api/users/<id>:min:2026-10-29-23-35
65
- PATCH /api/users/42 → demo:API:PATCH:api/users/<id>:min:2026-10-29-23-35
66
- GET /products/7 → demo:WEB:GET:products/<id>:min:2026-10-29-23-35
67
- ```
68
-
69
- Each key reads `{prefix}:{TYPE}:{ACTION}:{name}:{bucket}:{bucket_id}`, and its value is the number of requests in that period. [Redis keys](#redis-keys) explains each part.
70
-
71
- Your routes don't change. Next, [set up your framework](#set-up-your-framework), then [read the counts](#read-the-counts).
72
-
73
- ### Set up your framework
74
-
75
- Two parts, both in this section for each framework:
76
-
77
- - **Server**: call `statis.init(...)` once, as early as possible, in the process that serves your requests. This counts every request.
78
- - **Browser**: only for apps where moving to another page doesn't reload it (React, Vue, Angular, Svelte, Next.js, Nuxt, SvelteKit, Remix). Add one script to the HTML every page shares, so page changes are counted too. system-tracker serves it from your server; there's nothing else to install. See [Page views in single-page apps](#page-views-in-single-page-apps) for how it works.
79
-
80
- | Framework | Server: `statis.init` in | Browser: script in |
81
- | --- | --- | --- |
82
- | [Express](#express) | `server.ts` / `index.js` | not needed |
83
- | [NestJS](#nestjs) | `main.ts` | not needed |
84
- | [Fastify, Koa, plain Node](#fastify-koa-plain-node) | `server.ts` / `index.js` | not needed |
85
- | [Next.js, App Router](#nextjs-app-router) | `instrumentation.ts` | `app/layout.tsx` |
86
- | [Next.js, Pages Router](#nextjs-pages-router) | `instrumentation.ts` | `pages/_app.tsx` |
87
- | [Nuxt](#nuxt) | `server/plugins/system-tracker.ts` | `nuxt.config.ts` |
88
- | [SvelteKit](#sveltekit) | `src/hooks.server.ts` | `src/app.html` |
89
- | [Remix, React Router](#remix-react-router) | `app/entry.server.tsx` | `app/root.tsx` |
90
- | [Astro](#astro) | `src/middleware.ts` | your layout, with `<ClientRouter />` only |
91
- | [React, Vue, Angular, Svelte (Vite, CRA, Angular CLI)](#react-vue-angular-svelte-spa) | your API server | `index.html` |
92
-
93
- The examples use `{ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" }`; see [Connect to Redis](#connect-to-redis) for the other ways to connect.
94
-
95
- #### Express
96
-
97
- Server (`server.ts`):
98
-
99
- ```ts
100
- import express from "express";
101
- import { statis } from "system-tracker";
102
-
103
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
104
-
105
- const app = express();
106
- app.use(express.json());
107
-
108
- app.get("/api/users/:id", (req, res) => {
109
- res.json({ id: req.params.id }); // counted as demo:API:GET:api/users/<id>
110
- });
111
-
112
- app.listen(4000);
113
- ```
114
-
115
- Browser: not needed for pages rendered on the server (EJS, Pug, Handlebars), since every link loads a new page. If Express serves a React, Vue or Angular build, add the script to that app's `index.html` as in [React, Vue, Angular, Svelte](#react-vue-angular-svelte-spa).
116
-
117
- #### NestJS
118
-
119
- Server (`main.ts`):
120
-
121
- ```ts
122
- import { NestFactory } from "@nestjs/core";
123
- import { statis } from "system-tracker";
124
- import { AppModule } from "./app.module";
125
-
126
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
127
-
128
- async function bootstrap() {
129
- const app = await NestFactory.create(AppModule);
130
- await app.listen(3000);
131
- }
132
- bootstrap();
133
- ```
134
-
135
- Browser: not needed; same as Express.
136
-
137
- #### Fastify, Koa, plain Node
138
-
139
- Server: put the same call at the top of `server.ts` / `index.js`, before the server starts listening:
140
-
141
- ```ts
142
- import { statis } from "system-tracker";
143
-
144
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
145
-
146
- // ...create and start your server
147
- ```
148
-
149
- Browser: not needed; same as Express.
150
-
151
- #### Next.js, App Router
152
-
153
- Server (`instrumentation.ts`, next to `app/` or in `src/`):
154
-
155
- ```ts
156
- export async function register() {
157
- if (process.env.NEXT_RUNTIME === "nodejs") {
158
- const { statis } = await import("system-tracker");
159
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
160
- }
161
- }
162
- ```
163
-
164
- Browser (`app/layout.tsx`):
165
-
166
- ```tsx
167
- import Script from "next/script";
168
-
169
- export default function RootLayout({ children }: { children: React.ReactNode }) {
170
- return (
171
- <html lang="en">
172
- <body>
173
- {children}
174
- {/* system-tracker: counts page changes made in the browser */}
175
- <Script src="/system-tracker/view.js" strategy="afterInteractive" />
176
- </body>
177
- </html>
178
- );
179
- }
180
- ```
181
-
182
- If your app sets a Content Security Policy with a nonce, pass it to the `<Script>`; see [Content Security Policy](#content-security-policy).
183
-
184
- #### Next.js, Pages Router
185
-
186
- Server (`instrumentation.ts`): the same as the [App Router](#nextjs-app-router).
187
-
188
- Browser (`pages/_app.tsx`):
189
-
190
- ```tsx
191
- import Script from "next/script";
192
- import type { AppProps } from "next/app";
193
-
194
- export default function App({ Component, pageProps }: AppProps) {
195
- return (
196
- <>
197
- <Component {...pageProps} />
198
- <Script src="/system-tracker/view.js" strategy="afterInteractive" />
199
- </>
200
- );
201
- }
202
- ```
203
-
204
- #### Nuxt
205
-
206
- Server (`server/plugins/system-tracker.ts`):
207
-
208
- ```ts
209
- import { statis } from "system-tracker";
210
-
211
- export default defineNitroPlugin(() => {
212
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
213
- });
214
- ```
215
-
216
- Browser (`nuxt.config.ts`):
217
-
218
- ```ts
219
- export default defineNuxtConfig({
220
- app: {
221
- head: {
222
- script: [{ src: "/system-tracker/view.js", defer: true }],
223
- },
224
- },
225
- });
226
- ```
227
-
228
- #### SvelteKit
229
-
230
- Server (`src/hooks.server.ts`, with `@sveltejs/adapter-node`):
231
-
232
- ```ts
233
- import { statis } from "system-tracker";
234
-
235
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
236
- ```
237
-
238
- Browser (`src/app.html`), inside `<head>`:
239
-
240
- ```html
241
- <script src="/system-tracker/view.js" defer></script>
242
- ```
243
-
244
- #### Remix, React Router
245
-
246
- Server (`app/entry.server.tsx`), at the top:
247
-
248
- ```ts
249
- import { statis } from "system-tracker";
250
-
251
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
252
- ```
253
-
254
- Browser (`app/root.tsx`), inside `<head>`:
255
-
256
- ```tsx
257
- <script src="/system-tracker/view.js" defer />
258
- ```
259
-
260
- #### Astro
261
-
262
- Server (`src/middleware.ts`, with `@astrojs/node`):
263
-
264
- ```ts
265
- import { defineMiddleware } from "astro:middleware";
266
- import { statis } from "system-tracker";
267
-
268
- statis.init({ redis: { host: "127.0.0.1", port: 6379 }, prefix: "demo" });
269
-
270
- export const onRequest = defineMiddleware((_context, next) => next());
271
- ```
272
-
273
- Browser: only needed with `<ClientRouter />` (view transitions), which changes pages without a reload. Then add it to the layout every page uses, inside `<head>`:
274
-
275
- ```astro
276
- <script is:inline src="/system-tracker/view.js" defer></script>
277
- ```
278
-
279
- #### React, Vue, Angular, Svelte (SPA)
280
-
281
- These apps are built into static files, so there's no server of their own to call `init` in. Call it in the API server they talk to (see [Express](#express), [NestJS](#nestjs) or [Fastify, Koa, plain Node](#fastify-koa-plain-node)).
282
-
283
- Browser (`index.html` for Vite and Create React App, `src/index.html` for Angular), inside `<head>`:
284
-
285
- ```html
286
- <!-- the API server also serves the app's files -->
287
- <script src="/system-tracker/view.js" defer></script>
288
-
289
- <!-- the app is served from somewhere else (a CDN, or a dev server on another port) -->
290
- <script src="https://api.example.com/system-tracker/view.js" data-count-first defer></script>
291
- ```
292
-
293
- Use the API server's full URL when the pages come from somewhere else. `data-count-first` also counts the first page, since the API server never sees that page load.
294
-
295
- #### What works where
296
-
297
- | Framework | Requests counted | Route names | GraphQL / JSON-RPC |
298
- | --- | --- | --- | --- |
299
- | Express 4 / 5 | yes | Your Express routes | yes |
300
- | NestJS (Express) | yes | Your controller routes | yes |
301
- | Plain Node `http` / `https` | yes | Cleaned-up URL | yes |
302
- | Fastify, Koa | yes | Cleaned-up URL | yes |
303
- | Next.js, Nuxt, SvelteKit, Astro (Node) | yes | Cleaned-up URL | yes |
304
-
305
- - Requests only reach the tracker if your Node process serves them. A site served by a CDN, Nginx or an edge runtime isn't counted.
306
- - `init` runs when that file is first loaded. Requests that arrive before then aren't counted.
307
-
308
- In tests, turn tracking off:
309
-
310
- ```ts
311
- statis.init({
312
- redis: { host: "127.0.0.1", port: 6379 },
313
- prefix: "demo",
314
- enabled: process.env.NODE_ENV !== "test",
315
- });
316
- ```
317
-
318
- With `enabled: false`, `init` does nothing: no Redis connection and no hooks.
319
-
320
- ### Page views in single-page apps
321
-
322
- In single-page apps, moving to another page happens in the browser without a reload, so your server sees only the first page load. The script added in [Set up your framework](#set-up-your-framework) reports each page change to your server.
323
-
324
- Each time the path changes (`history.pushState`, `replaceState`, the back and forward buttons, or a `#/...` hash route), the script sends the new path to `POST /system-tracker/view` with `navigator.sendBeacon`. It's counted as a `WEB` page view under the same name as a full page load, so `/products/12` and `/products/7` both count as `products/<id>`. Only the path counts: a new query string or `#section` on the same page doesn't. `track.WEB`, `ignore.paths` and `ignore.bots` apply as usual. The first page load is counted by your server, as before, so it isn't counted twice. Requests for the script and its reports never reach your routes and aren't counted as requests of their own.
325
-
326
- With `enabled: false`, the script isn't served. The tag then gets a 404, which doesn't affect your pages.
327
-
328
- Next.js fetches a page's data when the browser moves to it (requests with an `RSC: 1` header in the App Router, and `/_next/data/...` in the Pages Router). Those aren't counted as `API` calls; the script counts the page change instead.
329
-
330
- #### Content Security Policy
331
-
332
- If your app sets one:
333
-
334
- - With a list of allowed sources, allow the script's origin in `script-src` and, when it's another server, in `connect-src`.
335
- - With a nonce (`'nonce-…'`, often with `'strict-dynamic'`), pass the same nonce your other scripts get. In Next.js, read it in the layout and hand it to `<Script>`:
336
-
337
- ```tsx
338
- import { headers } from "next/headers";
339
- import Script from "next/script";
340
-
341
- // inside your async RootLayout
342
- const nonce = (await headers()).get("x-nonce") ?? undefined; // the header your middleware sets
343
- // ...
344
- <Script src="/system-tracker/view.js" strategy="afterInteractive" nonce={nonce} />
345
- ```
346
-
347
- Without it, the browser blocks the script and page changes aren't counted.
348
-
349
- ### Dashboard
350
-
351
- A built-in dashboard shows these counts in your browser. It's off by default. Turn it on in the same `init` call:
352
-
353
- ```ts
354
- statis.init({
355
- redis: { host: "127.0.0.1", port: 6379 },
356
- prefix: "demo",
357
- dashboard: {
358
- enable: true, // default: false
359
- url: "/system-tracker", // default: "/system-tracker"
360
- username: "admin", // optional: no login when omitted
361
- password: "change-me", // optional: set both or neither
362
- },
363
- });
364
- ```
365
-
366
- Then open `/system-tracker` on your app's own host and port, for example `http://localhost:4000/system-tracker`. Your app's log prints the full URL once, when its server starts (or, when system-tracker starts after the server, as with Next.js, on the first request):
367
-
368
- ```text
369
- [system-tracker] dashboard: http://localhost:4000/system-tracker (sign in with dashboard.username and dashboard.password)
370
- ```
371
-
372
- | Option | Default | What it does |
373
- | --- | --- | --- |
374
- | `dashboard.enable` | `false` | Serve the dashboard |
375
- | `dashboard.url` | `"/system-tracker"` | The path it's served at (leading and trailing `/` are optional) |
376
- | `dashboard.username`, `dashboard.password` | — | Show a sign-in page. Set both or neither |
377
-
378
- Four tabs, and each part loads only when you open it:
379
-
380
- - **Overview**: your default board, when you set one (see [Boards](#boards)). Otherwise the built-in overview, with a range picker at the top: the last minute (per second), hour (per minute), 24 hours, 7 days, 30 days, 12 months or 5 years. Everything on the page covers that range: the requests in it, the current second, minute, hour, day, month or year next to the one before, the average and the busiest one; traffic stacked by type (hover a bar for each type and the total); one row per type with its trend, its current bucket (for example today), its total for the range (for example the last 7 days) and its share of that total; and the top routes, the same way. Outgoing calls (`OUT`) are listed too, split into calls to **your backend** and to **third parties** (see [Your own domains](#your-own-domains)), but they aren't requests to your app, so they're not part of the total, the stacked chart or the shares, just like `{prefix}:global` leaves them out. The most important insights sit above them. The totals come from the `{prefix}:global` and `{prefix}:{TYPE}:global` keys, read by exact name; the routes of the last minute or hour are read by name for the routes seen in the last two hours, and longer ranges with one `SCAN`, cached for 15 seconds. **Customize** turns it into a board you can rearrange; saving it makes it your Overview.
381
- - **Type** (click a row or a bar): the same range picker, and for that range its numbers, 5xx rate, share by route, methods (for `OUT`: your backend vs third-party), and every route with its requests, its current bucket and, with `failures: true`, its 5xx. Filter and sort them.
382
- - **Route** (click a route): one tile per bucket (seconds to years), which also picks the range, with how long each bucket is kept; the total, average, peak and lowest of the range, its failure rate, a chart with 5xx in red, and the raw bucket values. Below them, the route's [recent requests and failures](#recent-requests-and-failures) as curl commands.
383
- - **Insights**: stats and problems for the last 15 minutes, the last hour, today, this week, this month, this quarter or this year, with reports you can export. Problems are grouped as critical, warning, spikes, drops, high traffic, scale, failures and new routes. Click one to open that route or type at the right time range. See [Insights](#insights).
384
- - **Boards**: your own dashboards. See [Boards](#boards).
385
- - **Settings**: try other settings and get the `init()` code for them. See [Settings](#settings-tab).
386
-
387
- **Storage**: the top bar shows how much of Redis is in use and the tracker's share, for example `Redis 230 MB / 2 GB · tracker ≈28 MB`. It turns amber when Redis is 80% full. Hover it for the details, or click it for the **Storage in Redis** card on the Settings tab. That card has a bar for the tracker, everything else and the free space, and a table of the tracker's keys by group: each time bucket (with how long it's kept), recent requests, failures, boards and reports, with their key counts and sizes. Redis's own numbers come from `INFO memory` (`used_memory`, and `maxmemory`, or the server's RAM when no `maxmemory` is set) and `DBSIZE`, added up across cluster masters. The tracker's share comes from a `SCAN` of its keys in the background, at most once every 10 minutes and only while the dashboard is open. Sizes are estimated from `MEMORY USAGE` on a random sample of each group, so they're approximate. "Other" is everything else in Redis: other apps' keys and Redis's own overhead. **Recount** starts a new count (at most every 30 seconds). If your Redis provider blocks `INFO` or `MEMORY USAGE`, the card shows what it can, such as key counts only.
388
-
389
- Hover any chart, including the small trend lines, to see the time and the value under the cursor. Click a chart's legend to hide or show a series.
390
-
391
- #### Insights
392
-
393
- **Stats by period**: pick the last 15 minutes, the last hour, today, this week (from Monday), this month, this quarter or this year, all in IST. The whole tab follows it: the stats, the three summary cards and the list of problems. You get the requests and how they compare with the period before, 5xx and their share, how many routes were called, the busiest minute, hour, day or month, a chart over the period, the split by type and the top 10 routes. Periods are read from the `min`, `day`, `month` and `year` buckets with one `SCAN` each, cached for 30 seconds, so they need those buckets turned on and kept long enough (a week needs 7 days of `day` buckets).
394
-
395
- **Export** (on every screen: the Overview, a type, a route, a board, a template and Settings): downloads what the screen shows, at the range picked on it. That's the numbers at the top, each chart's data over time and each table (for a route: its buckets, newest first; for a board: every chart and table on it; for Settings: storage and what's tracked). Pick CSV, PDF (a print view) or JSON. A route's recent requests and failures are left out, since they can hold tokens. Each export is saved with the reports below, so you can download it again later.
396
-
397
- **Export report** (Insights): pick a period, then CSV (for Excel or Google Sheets), PDF (a print view; choose "Save as PDF") or JSON, then click Export. A report has the totals, the chart, the split by type and up to 500 routes with their 5xx. Each export is also saved in your Redis, so the dialog lists earlier reports to download again in any format, or delete. They're kept at `system-tracker:reports:{prefix}` (a list) and `system-tracker:report:{prefix}:{id}` (one string per report), the newest 100.
398
-
399
- **Reset data** (on the Overview and Insights): deletes counts for good. Pick what (everything, one type or one route) and when (all time, today, this week, this month, this quarter or this year). The dashboard first shows how many counters it will delete, then asks you to confirm. Only system-tracker's own counter keys under your prefix are deleted, found with `SCAN` and checked against the key format, so other keys stay, and so do your boards and saved reports. Totals that include the deleted counts are lowered to match: deleting today lowers this month and this year, and deleting one route lowers its type's total and the overall total. Reset needs a dashboard login (`dashboard.username` and `dashboard.password`), so a dashboard without one can't delete anything. Each reset is written to your app's log, for example `[system-tracker] dashboard reset (everything, all time): deleted 1345 counters, lowered 0 totals`.
400
-
401
- **Problems**: the rules below are checked over the picked period, against the period before it. Spikes, drops and recent failures use steps that fit the period: minutes for the last 15 minutes and the last hour, hours for today, days for a week or month, months for a quarter or year. Every complete step inside the period is searched, so a burst ten minutes ago still shows in the last hour, with its time ("40× its usual traffic at 17:31"); the usual level is the median of the 30 steps before it, or their average when most are empty, and hours and days are compared with the same hour or weekday earlier. Today and longer periods also list what the last hour found minute by minute, tagged "last hour", since a burst of a few minutes doesn't show in hourly or daily totals. The badge on the Insights tab and the Overview always show what's unusual right now (the last 30 minutes and today, like this table); the badge refreshes on every page, and those alerts are always in the list too, tagged "right now", whatever the period. Top routes can be folded away; the dashboard remembers it. Each refresh checks every route counted today, and the minute-by-minute traffic of up to 100 of them (the busiest, plus any with failures):
402
-
403
- | Group | When | Level |
404
- | --- | --- | --- |
405
- | Failures | 5xx are 5% or more of a route's calls today (at least 50 calls), or 20% or more in the last 10 minutes | critical |
406
- | Failures | 5xx are 1% or more today | warning |
407
- | Spike | the last full minute is 3× the route's usual minute (the median of the 30 before it) and at least 30 calls more. A client stuck in a loop looks like this | warning; critical at 10× |
408
- | Drop | a route that usually gets 30+ calls a minute got 20% or less of that over the last 3 minutes | warning |
409
- | High traffic | one route has 40% or more of all calls today (with at least 10,000 calls and 3 routes); or today is on track for 1.5× yesterday | info |
410
- | Scale | a type uses 80% of `limits.maxNamesPerType`; calls were counted as `<other>`; or 10% of a type's calls matched no route | warning or info |
411
- | New | a route has calls today and none yesterday | info |
412
-
413
- #### Recent requests and failures
414
-
415
- Each route's page lists its recent requests, so you can see what's being sent and replay it. That includes outgoing calls (`OUT`): the call your app made to your backend or a third party, with its body. Pick the format at the top:
416
-
417
- - **curl sample** (the default): the request exactly as it was sent, ready to run.
418
- - **curl structure**: the route with its parameters, and every value replaced by its type. Use it to see the shape of a request. The browser remembers your choice.
419
-
420
- curl structure:
7
+ ## Quick start
421
8
 
422
9
  ```bash
423
- curl -X POST 'https://api.example.com/api/users/:userId/profile?lang=<string>' \
424
- -H 'content-type: application/json' \
425
- -H 'authorization: <authorization>' \
426
- --data-raw '{
427
- "username": "<string>",
428
- "age": "<number>"
429
- }'
430
- ```
431
-
432
- curl sample:
433
-
434
- ```bash
435
- curl -X POST 'https://api.example.com/api/users/42/profile?lang=en' \
436
- -H 'content-type: application/json' \
437
- -H 'authorization: Bearer eyJhbGciOi…' \
438
- --data-raw '{"username":"jay","age":10}'
439
- ```
440
-
441
- **Copy** puts the command on your clipboard. Paste it in a terminal, or in Postman with **Import → Raw text**. In a GraphQL body, the query and operation name stay as they are and only the variables become types; in a JSON-RPC body, so does the method.
442
-
443
- - **Recent requests**: the last 5 per route, at most one a minute, newest first. Names that many different requests share (`<unmatched>`, `<other>`, `<unknown>`, `<anonymous>`) keep the last 20, at most one every 2 seconds, so you can see what's behind them.
444
- - **Recent failures**: the last 20 requests answered with status 500 or above, at most one a second per route. Each also has the start of the error response (up to 2 KB) and, in Express apps, the stack of the error passed to `next(err)` or thrown in a route. For outgoing calls, a call that got no response at all (a reset connection or a timeout) is a failure too, with its error.
445
- - **No duplicates**: a request with the same method, URL (path and query) and body as one already in the list isn't added again. That entry moves to the top with the latest time, response and stack, and shows how many times it was captured (×3). So the list holds different requests, and repeats don't push them out.
446
-
447
- What's kept:
448
-
449
- - The method, URL, headers and body (up to 16 KB) as they were sent, including `Authorization`, cookies and API keys. Only connection headers (`Host`, `Content-Length`, `Connection`, `Accept-Encoding`, …) are left out; the host is part of the URL. Because of this, set a dashboard login, and keep your Redis private.
450
- - HTTP requests your app receives, and outgoing calls made with `fetch`, `http` or `https` (axios, got and node-fetch use these). An outgoing body is kept when it's text: a string, `URLSearchParams` or a buffer for `fetch`, and whatever is written to the request for `http` and `https`. Form data and streams aren't read. WebSocket traffic and gRPC have no curl.
451
- - Saved in your Redis as lists at `system-tracker:samples:{prefix}:{TYPE}:{ACTION}:{name}` and `system-tracker:failures:{prefix}:{TYPE}:{ACTION}:{name}`, kept 30 days after their last write. Resetting a route, a type or everything for all time deletes them too.
452
- - Only while the dashboard is on. Without `dashboard.enable`, nothing is kept. So a route can have counts but no requests to show: counts from before the dashboard was turned on, or older than 30 days. The next request shows up within a minute.
453
-
454
- #### Settings tab
455
-
456
- Toggle what to track, totals, failures, skipped requests, time buckets and limits. A change is only a preview: the dashboard hides what you turned off, and your app keeps tracking as before. Below the settings, your current `statis.init()` call is shown with the change applied, new lines in green and removed lines in red. Copy it into your code and restart to apply it. Credentials (Redis and dashboard usernames and passwords, and the ones in a Redis URL) are hidden on the server and never reach the browser.
457
-
458
- #### Boards
459
-
460
- Build dashboards of your own. The Boards tab has two parts:
461
-
462
- - **Default templates**: ten ready-made boards. Open one to see it with your live data, then **Use this template** (saves a copy) or **Customize** it first. A template itself never changes; saving always creates your own copy. Charts that need a type your app doesn't track, or `failures: true`, are left out.
463
-
464
- | Template | For | Shows |
465
- | --- | --- | --- |
466
- | Everyday overview | everyone | insights, totals, traffic by type, the mix, busiest routes, 30 days |
467
- | DevOps / SRE | on-call, operations | requests per second, 5xx, failure rate, spikes, routes with 5xx |
468
- | Product owner | product | daily and monthly usage, most used features and pages, pages vs API |
469
- | Backend developer | developers | API routes with 5xx, calls by HTTP method, GraphQL and RPC operations |
470
- | QA & testing | QA | 5xx per minute and per route, failure rate, anything new or unusual |
471
- | API health | API teams | API calls, failures and failure rate, route by route |
472
- | Live wall | big screens | per-second traffic and this minute, with Live refresh |
473
- | Capacity planning | engineering leads | hours, days, months and years, and how the mix changes |
474
- | Web & pages | frontend | page views next to API calls, top pages, daily trend |
475
- | Integrations & realtime | platform | outgoing calls by host, WebSocket, SSE, gRPC, GraphQL and RPC |
476
-
477
- - **Your templates**: the boards you saved, plus a blank board and a customized Overview.
478
-
479
- Add charts in four steps:
480
-
481
- 1. **What to track**: all traffic, a type, one route, or a pattern with `*` wildcards (for example type `API`, method `*`, name `api/users*`). The editor shows which routes a pattern matches. Add up to 6 series to compare them on one chart.
482
- 2. **Show as**: number, line, area, bars, stacked bars, donut, table or insights. Donuts and tables split a type or pattern by route, all traffic by type, several series against each other, and one route into calls with and without 5xx, so you can switch any chart to any kind.
483
- 3. **Measure**: requests, 5xx or failure rate (5xx needs `failures: true`).
484
- 4. **Range and size**: the last minute (per second), hour (per minute), 24 hours, 30 days, 12 months or 5 years; then the size on the grid.
485
-
486
- The pencil next to a board's name, on the Boards tab and on the board itself, renames it right away. **Edit board** also lets you rename it, along with everything else.
487
-
488
- Click **Edit board** to change a board: drag a chart to move it (the others make room as you go and keep their sizes), drag any edge or corner to resize it, add, edit, duplicate or remove charts, and rename the board. When a chart gets wider than its row allows, the next charts move to the row below. While you edit, clicking a chart doesn't open anything, there's extra room below the last chart, and the page scrolls when you drag near its edge. Nothing changes until you click **Save**, which stores the board and opens it; **Cancel** drops the changes. Templates and **Customize** on the Overview open the same way, as an unsaved draft.
489
-
490
- **Set as default** makes a board your Overview: the Overview tab and the dashboard URL open it. **Reset to default Overview** brings back the built-in one. Saving a customized overview makes it your default board.
491
-
492
- Charts sit on a grid four units wide (two on tablets, one on phones), and sizes snap to whole units, width × height:
493
-
494
- | Chart | Smallest | Largest |
495
- | --- | --- | --- |
496
- | Number | 1×1 | 2×2 |
497
- | Line, area, bars | 1×1 | 4×3 |
498
- | Stacked bars, insights | 2×1 | 4×3 |
499
- | Donut | 2×2 | 4×3 |
500
- | Table | 2×2 | 4×4 |
501
-
502
- Charts on boards work like the built-in ones: hover for the crosshair and values, click the legend to hide a series, and click a bar, number, row or slice to open that route or type. Tables and donuts add up the whole range they show. Only types your app tracks can be charted. Text that doesn't fit ends in "…"; hover it to see all of it.
503
-
504
- Boards are saved in your Redis, as one JSON string at `system-tracker:dashboards:{prefix}` (at most 50 boards and 256 KB). Restarts and redeploys keep them, and every instance of your app and everyone using the dashboard shares them. Each save is also logged to the browser console.
505
-
506
- How it behaves:
507
-
508
- - Your app's own server answers it (Express, NestJS, Fastify, Koa, Next.js or plain Node). No extra port, no extra dependency. Dashboard requests never reach your routes and are never counted.
509
- - Pick how often it refreshes: live (every second), 5 s, 30 s (the default), 1 min, 5 min, 15 min, or off. ↻ refreshes right away. It doesn't refresh while the tab is hidden. A chart under your mouse pointer waits, so what you're reading holds still; it catches up as soon as the pointer leaves.
510
- - It's dark by default; the theme button switches to light. The browser remembers both choices.
511
- - It works on any screen size: on tablets and phones the layout stacks, tables scroll sideways and charts keep their proportions.
512
- - With a username and password, signing in sets an `HttpOnly`, `SameSite=Strict` cookie for 7 days. Changing the password signs everyone out.
513
- - Without them, anyone who can reach the URL can see your routes and traffic, the recent requests with their headers and bodies, and change your boards. The page shows a warning, and the server logs one at startup. In production, set a login and serve it over HTTPS.
514
- - Times are in IST, like the bucket ids in the keys.
515
-
516
- ### Read the counts
517
-
518
- `searchKeys(pattern)` finds keys using Redis patterns (`*` matches anything). With a cluster it searches every node. `statRedis` is the connected ioredis client.
519
-
520
- ```ts
521
- import { searchKeys, statRedis } from "system-tracker";
522
-
523
- async function todaysApiCounts() {
524
- const keys = await searchKeys("demo:API:*:day:2026-10-29");
525
-
526
- return Promise.all(
527
- keys.map(async (key) => ({
528
- key,
529
- value: Number((await statRedis.get(key)) || 0),
530
- })),
531
- );
532
- }
533
- ```
534
-
535
- More patterns:
536
-
537
- ```ts
538
- searchKeys("demo:API:DELETE:*:day:2026-10-29"); // every DELETE today
539
- searchKeys("demo:API:*:api/users/<id>:hour:*"); // all methods on one route, every hour
540
- searchKeys("demo:WEB:*:day:2026-10-29"); // all page views today
541
- searchKeys("demo:RPC:CALL:*:min:2026-10-29-23-*"); // JSON-RPC calls per minute in one hour
542
- searchKeys("demo:*:5xx:day:2026-10-29"); // every failure key today
543
- searchKeys("demo:global:day:*"); // daily totals
544
- searchKeys("demo:OUT:*:day:2026-10-29"); // outgoing calls today
545
- ```
546
-
547
- A stats endpoint (Express):
548
-
549
- ```ts
550
- app.get("/api/statistics", async (req, res) => {
551
- const pattern = String(req.query.pattern || "demo:global:min:*");
552
- const keys = await searchKeys(pattern);
553
-
554
- res.json(
555
- await Promise.all(
556
- keys.map(async (key) => ({ key, value: Number((await statRedis.get(key)) || 0) })),
557
- ),
558
- );
559
- });
560
- ```
561
-
562
- That endpoint is itself counted (`demo:API:GET:api/statistics`). Add it to `ignore.paths` if you don't want it in your numbers.
563
-
564
- - `statRedis` works with any Redis command (`get`, `ttl`, `del`, …).
565
- - Errors are logged and return `null` instead of throwing.
566
- - Before Redis is set up, calls log `redis not initialized` and return `null`.
567
-
568
- ### Connect to Redis
569
-
570
- Pass the connection and the `prefix` straight to `init`. system-tracker never reads environment variables, so there are no variable names to remember. If you keep these values in your own config or env files, read them however you like and pass them in.
571
-
572
- A Redis URL:
573
-
574
- ```ts
575
- statis.init({
576
- redis: "redis://default:secret@127.0.0.1:6379",
577
- prefix: "production",
578
- });
579
- ```
580
-
581
- - `redis://` or `rediss://` (TLS). The port defaults to 6379; add `/2` at the end to use database 2.
582
- - Any other URL makes `init` throw an error.
583
-
584
- Standalone:
585
-
586
- ```ts
587
- statis.init({
588
- redis: { host: "127.0.0.1", port: 6379 },
589
- prefix: "demo",
590
- });
10
+ npm install system-tracker # or: yarn add system-tracker
591
11
  ```
592
12
 
593
- Secure standalone:
13
+ Add this at the top of your app's entry file, before your server starts:
594
14
 
595
15
  ```ts
596
- statis.init({
597
- redis: { host: "127.0.0.1", port: 6379, username: "default", password: "secret" },
598
- prefix: "demo",
599
- });
600
- ```
601
-
602
- Cluster:
603
-
604
- ```ts
605
- statis.init({
606
- redis: {
607
- nodes: [
608
- { host: "127.0.0.1", port: 7000 },
609
- { host: "127.0.0.1", port: 7001 },
610
- { host: "127.0.0.1", port: 7002 },
611
- ],
612
- },
613
- prefix: "demo",
614
- });
615
- ```
616
-
617
- Secure cluster:
618
-
619
- ```ts
620
- statis.init({
621
- redis: {
622
- nodes: [
623
- { host: "127.0.0.1", port: 7000 },
624
- { host: "127.0.0.1", port: 7001 },
625
- { host: "127.0.0.1", port: 7002 },
626
- ],
627
- username: "default",
628
- password: "secret",
629
- },
630
- prefix: "demo",
631
- });
632
- ```
633
-
634
- Your own ioredis client (standalone):
635
-
636
- ```ts
637
- import Redis from "ioredis";
638
- import { statis } from "system-tracker";
639
-
640
- const client = new Redis({ host: "127.0.0.1", port: 6379 });
641
-
642
- statis.init({
643
- redis: { client, type: "standalone" },
644
- prefix: "demo",
645
- });
646
- ```
647
-
648
- Your own ioredis client (cluster):
649
-
650
- ```ts
651
- import Redis from "ioredis";
652
16
  import { statis } from "system-tracker";
653
17
 
654
- const client = new Redis.Cluster([
655
- { host: "127.0.0.1", port: 7000 },
656
- { host: "127.0.0.1", port: 7001 },
657
- { host: "127.0.0.1", port: 7002 },
658
- ]);
659
-
660
- statis.init({
661
- redis: { client, type: "cluster" },
662
- prefix: "demo",
663
- });
664
- ```
665
-
666
- If `type` doesn't match the client (a cluster passed as `"standalone"`, or the reverse), `init` throws an error.
667
-
668
- Use a different `prefix` per app or environment (`production`, `staging`, `development`) so their counts never mix.
669
-
670
- ## Settings
671
-
672
- All settings go in the same `statis.init({...})` call. You only add the ones you want to change.
673
-
674
- ### Expiry
675
-
676
- Choose which buckets to keep and for how long:
677
-
678
- ```ts
679
- statis.init({
680
- redis: { host: "127.0.0.1", port: 6379 },
681
- prefix: "demo",
682
- timeConfig: {
683
- sec: { enable: true, expiry_in_sec: 60 * 60 }, // 1 hour
684
- min: { enable: true }, // default: 1 day
685
- hour: { enable: true }, // default: 1 day
686
- day: { enable: true, expiry_in_sec: 90 * 24 * 60 * 60 }, // 90 days
687
- month: { enable: false },
688
- year: { enable: false },
689
- },
690
- });
691
- ```
692
-
693
- Defaults:
694
-
695
- | Bucket | Enabled | Expiry |
696
- | --- | --- | --- |
697
- | `sec` | yes | 1 hour |
698
- | `min` | yes | 1 day |
699
- | `hour` | yes | 1 day |
700
- | `day` | yes | 30 days |
701
- | `month` | yes | 180 days |
702
- | `year` | yes | 10 years |
703
-
704
- - Any bucket you leave out keeps its default.
705
- - `expiry_in_sec` must be a whole number of seconds, 0 or more. Anything else makes `init` throw an error.
706
- - The expiry is set when a key is created, so a key lives for its expiry from its first count.
707
- - Each counted request writes one Redis key per enabled bucket, and more when totals or failure keys are on. Turning off buckets you don't need reduces Redis writes.
708
-
709
- ### Skip requests
710
-
711
- ```ts
712
- statis.init({
713
- // ...
714
- ignore: {
715
- paths: ["/health", "/metrics", "/favicon.ico", "/internal/*"],
716
- methods: ["OPTIONS", "HEAD"], // default
717
- bots: true, // skip search engines and crawlers (default: false)
718
- },
719
- });
720
- ```
721
-
722
- - `paths`:
723
- - `"/health"` matches `/health` and `/health/`, but not `/healthz`.
724
- - `"/internal/*"` matches `/internal` and everything below it.
725
- - The query string is ignored.
726
- - Ignored paths also apply to WebSocket connections.
727
- - `methods`: the list replaces the default. Pass `[]` to count `OPTIONS` and `HEAD` too.
728
- - `bots`: skips requests whose `User-Agent` contains `bot`, `crawl`, `spider`, `slurp`, `bingpreview`, `facebookexternalhit` or `headless`.
729
-
730
- ### Choose what to track
731
-
732
- Every type, with its default:
733
-
734
- ```ts
735
- statis.init({
736
- // ...
737
- track: {
738
- API: true, // JSON / REST endpoints demo:API:GET:api/users/<id>
739
- WEB: true, // pages (HTML responses) demo:WEB:GET:products/<id>
740
- GRAPHQL: true, // GraphQL operations demo:GRAPHQL:QUERY:GetUser
741
- RPC: true, // JSON-RPC calls demo:RPC:CALL:eth_getBalance
742
- WSS: true, // WebSocket connections, messages and Socket.IO events
743
- // demo:WSS:CONNECT:ws/chat
744
- SSE: true, // server-sent event streams demo:SSE:GET:events/prices
745
- GRPC: true, // gRPC calls demo:GRPC:CALL:user.UserService/GetUser
746
- OTHER: true, // requests that match no route demo:OTHER:GET:<unmatched>
747
- STATIC: false, // JS, CSS, images, fonts demo:STATIC:GET:assets/<file>
748
- OUT: false, // calls your app makes to others demo:OUT:GET:rpc-testnet.supra.com/rpc/v1/block
749
- },
750
- global: {
751
- total: true, // demo:global:{bucket}:{bucket_id} — every request
752
- perType: true, // demo:{TYPE}:global:{bucket}:{bucket_id} — every request of one type
753
- },
754
- failures: false, // true adds demo:{TYPE}:{ACTION}:{name}:5xx:… for status 500 and above
755
- });
756
- ```
757
-
758
- You only need to list what you change. Anything you leave out keeps the default above. For example, to turn on files and outgoing calls and turn off WebSockets:
759
-
760
- ```ts
761
- statis.init({
762
- // ...
763
- track: { STATIC: true, OUT: true, WSS: false },
764
- });
765
- ```
766
-
767
- Turning a type off:
768
-
769
- - `track.API: false`: JSON/REST requests are not counted at all.
770
- - `track.WEB: false`: page views are not counted.
771
- - `track.GRAPHQL: false` / `track.RPC: false`: those calls are not counted. They do not fall back to `API`.
772
- - `track.WSS: false`: no `CONNECT`, `MESSAGE` or `EVENT` keys.
773
- - `track.SSE: false`, `track.GRPC: false`, `track.OTHER: false`: that type is not counted.
774
- - `track.STATIC: true`: files are counted under `STATIC`. While it is off, files are not counted anywhere.
775
- - `track.OUT: true`: outgoing `http`, `https` and `fetch` calls are counted.
776
- - [Custom types](#custom-types) are always counted. `track` doesn't apply to them.
777
-
778
- Totals:
779
-
780
- | Setting | Keys written | Example |
781
- | --- | --- | --- |
782
- | `global.total: true` | one per counted request, connection or gRPC call | `demo:global:min:2026-10-29-23-35` |
783
- | `global.perType: true` | one per type | `demo:API:global:min:2026-10-29-23-35`, `demo:WSS:global:min:…` |
784
- | both `false` | no totals, only per-route keys | — |
785
-
786
- `demo:global` leaves out WebSocket messages, Socket.IO events and outgoing calls. `demo:WSS:global` and `demo:OUT:global` still count them.
787
-
788
- Failures: with `failures: true`, a request answered with status 500 or higher gets one extra key, next to its normal one:
789
-
790
- ```text
791
- demo:API:GET:api/users/<id>:min:2026-10-29-23-35 all requests
792
- demo:API:GET:api/users/<id>:5xx:min:2026-10-29-23-35 failed requests
793
- ```
794
-
795
- The normal key still counts the failed request, so the failure rate is `5xx ÷ all`. Failure keys are only written for HTTP requests (any `TYPE` that has a status code), not for WebSocket messages, gRPC streams or outgoing calls.
796
-
797
- ### Custom types
798
-
799
- Give a group of routes its own `TYPE`:
800
-
801
- ```ts
802
- statis.init({
803
- // ...
804
- types: [
805
- { path: "/webhooks/*", type: "HOOK" }, // demo:HOOK:POST:webhooks/stripe:…
806
- { path: "/admin/*", type: "ADMIN" }, // demo:ADMIN:GET:admin/users:…
807
- ],
808
- });
809
- ```
810
-
811
- - `path` matches like `ignore.paths`. The first matching entry wins.
812
- - `type` is upper-cased (`"hook"` becomes `HOOK`).
813
- - The action is the HTTP method and the name follows [How routes are named](#how-routes-are-named).
814
- - Custom types are always counted; `track` doesn't apply to them.
815
-
816
- ### One URL, many operations
817
-
818
- GraphQL and JSON-RPC are detected automatically (see [What gets counted](#what-gets-counted)). If your app sends different operations to one URL some other way, tell `init` where to find the name in the request body:
819
-
820
- ```ts
821
- statis.init({
822
- // ...
823
- operations: [
824
- { path: "/action", type: "ACT", read: (body) => body.action },
825
- ],
826
- });
827
- ```
828
-
829
- ```text
830
- POST /action {"action":"swap"} → demo:ACT:CALL:swap:min:…
831
- POST /action {"action":"stake"} → demo:ACT:CALL:stake:min:…
832
- POST /action {} → demo:ACT:CALL:<unknown>:min:…
833
- ```
834
-
835
- - `read` gets the parsed JSON body and must return a non-empty string.
836
- - Anything else, including an error thrown inside `read`, counts as `<unknown>`.
837
- - The action is always `CALL`, and `type` is upper-cased.
838
-
839
- ### Name requests yourself
840
-
841
- For full control, return the type, action and name for a request. Return `undefined` to use the normal rules:
842
-
843
- ```ts
844
- statis.init({
845
- // ...
846
- name: (req) => {
847
- if (req.url?.startsWith("/legacy/")) {
848
- return { type: "LEGACY", action: req.method ?? "GET", name: "legacy" };
849
- }
850
- return undefined;
851
- },
852
- });
853
- ```
854
-
855
- ```text
856
- GET /legacy/anything → demo:LEGACY:GET:legacy:min:…
857
- ```
858
-
859
- - `req` is Node's `IncomingMessage`.
860
- - All three values must be non-empty. `:` in them is replaced with `/`.
861
- - Built-in type names you return still respect `track`.
862
-
863
- ### Limits
864
-
865
- Some names come from outside your code: URLs typed by bots, Socket.IO event names, GraphQL operation names. Two limits keep the number of Redis keys bounded:
866
-
867
- ```ts
868
18
  statis.init({
869
- // ...
870
- limits: {
871
- maxNamesPerType: 200, // default
872
- collapseAfter: 50, // default
873
- },
19
+ redis: "redis://127.0.0.1:6379",
20
+ prefix: "my-app",
21
+ dashboard: { enable: true, username: "admin", password: "change-me" },
874
22
  });
875
23
  ```
876
24
 
877
- - `maxNamesPerType`: each `TYPE` keeps at most this many names. New names after that are counted as `<other>`:
878
-
879
- ```text
880
- demo:RPC:CALL:eth_getBalance:min:… ← one of the first 200 names
881
- demo:RPC:CALL:<other>:min:… ← everything after that
882
- ```
883
-
884
- - `collapseAfter`: once one position in a URL has seen more than this many different values, that position becomes `<slug>`:
885
-
886
- ```text
887
- /blog/post-1 … /blog/post-50 → blog/post-1 … blog/post-50
888
- /blog/post-51, and from then on
889
- every /blog/… page (old ones too) → blog/<slug>
890
- ```
891
-
892
- The limits are kept in memory and start again when the app restarts.
893
-
894
- ### All options
895
-
896
- | Option | Default | What it does |
897
- | --- | --- | --- |
898
- | `redis` | required | Redis URL or connection (see [Connect to Redis](#connect-to-redis)) |
899
- | `prefix` | required | First part of every key. Must not be empty or contain `:` |
900
- | `timeConfig` | see [Expiry](#expiry) | Buckets to keep and their expiry |
901
- | `ignore.paths` | `[]` | Paths to skip (see [Skip requests](#skip-requests)) |
902
- | `ignore.methods` | `["OPTIONS", "HEAD"]` | Methods to skip |
903
- | `ignore.bots` | `false` | Skip crawlers |
904
- | `track` | all on, except `STATIC` and `OUT` | Turn types on or off |
905
- | `global.total` | `true` | `{prefix}:global` keys |
906
- | `global.perType` | `true` | `{prefix}:{TYPE}:global` keys |
907
- | `failures` | `false` | Extra `:5xx` key for responses with status 500 or higher |
908
- | `types` | `[]` | Your own types by path (see [Custom types](#custom-types)) |
909
- | `operations` | `[]` | Read the operation name from the body (see [One URL, many operations](#one-url-many-operations)) |
910
- | `limits.maxNamesPerType` | `200` | Names beyond this are counted as `<other>` |
911
- | `limits.collapseAfter` | `50` | A URL part with more distinct values becomes `<slug>` |
912
- | `name` | — | `(req) => ({ type, action, name })` to name any request yourself |
913
- | `enabled` | `true` | `false` turns tracking off completely |
914
- | `ownDomains` | `[]` | Domains of your own backend, for outgoing calls (see [Your own domains](#your-own-domains)) |
915
- | `dashboard` | off | `{ enable, url, username, password }` (see [Dashboard](#dashboard)); `url` defaults to `"/system-tracker"` |
25
+ Start your app and open `/system-tracker` on it, for example `http://localhost:3000/system-tracker`.
916
26
 
917
- ## How it works
27
+ Using Next.js, Nuxt, SvelteKit or another framework? See [where to add it](guide/frameworks.md).
918
28
 
919
- ### Redis keys
29
+ ## Let your AI assistant set it up
920
30
 
921
- Each request adds 1 to a key like this:
31
+ Using Claude Code, Cursor or Copilot? Paste this. It looks through your project, asks you a few simple questions about what to track, and sets everything up:
922
32
 
923
- ```text
924
- {prefix}:{TYPE}:{ACTION}:{name}:{bucket}:{bucket_id}
925
33
  ```
34
+ Read and strictly follow the SKILL.md file to implement the npm package "system-tracker".
926
35
 
927
- | Part | Meaning | Example |
928
- | --- | --- | --- |
929
- | `prefix` | Your app or environment name | `demo` |
930
- | `TYPE` | Kind of request (see [What gets counted](#what-gets-counted)) | `API` |
931
- | `ACTION` | HTTP method, or the operation kind | `GET`, `QUERY`, `CALL` |
932
- | `name` | Route, without the leading `/` (see [How routes are named](#how-routes-are-named)) | `api/users/<id>` |
933
- | `bucket` | Time period | `sec`, `min`, `hour`, `day`, `month`, `year` |
934
- | `bucket_id` | The period itself, in IST (UTC+5:30) | `2026-10-29-23-35` |
935
-
936
- Bucket id formats:
937
-
938
- | Bucket | Format | Example |
939
- | --- | --- | --- |
940
- | `sec` | `YYYY-MM-DD-HH-mm-ss` | `2026-10-29-23-35-12` |
941
- | `min` | `YYYY-MM-DD-HH-mm` | `2026-10-29-23-35` |
942
- | `hour` | `YYYY-MM-DD-HH` | `2026-10-29-23` |
943
- | `day` | `YYYY-MM-DD` | `2026-10-29` |
944
- | `month` | `YYYY-MM` | `2026-10` |
945
- | `year` | `YYYY` | `2026` |
946
-
947
- One route across all buckets:
948
-
949
- ```text
950
- demo:API:PATCH:api/users/<id>:sec:2026-10-29-23-35-12 expires in 1 hour
951
- demo:API:PATCH:api/users/<id>:min:2026-10-29-23-35 expires in 1 day
952
- demo:API:PATCH:api/users/<id>:hour:2026-10-29-23 expires in 1 day
953
- demo:API:PATCH:api/users/<id>:day:2026-10-29 expires in 30 days
954
- demo:API:PATCH:api/users/<id>:month:2026-10 expires in 180 days
955
- demo:API:PATCH:api/users/<id>:year:2026 expires in 10 years
956
- ```
957
-
958
- Every HTTP method gets its own keys:
959
-
960
- ```text
961
- demo:API:GET:api/users:min:2026-10-29-23-30 48 ← list users
962
- demo:API:POST:api/users:min:2026-10-29-23-30 6 ← create user
963
- demo:API:GET:api/users/<id>:min:2026-10-29-23-30 212 ← get one user
964
- demo:API:PUT:api/users/<id>:min:2026-10-29-23-30 9 ← replace user
965
- demo:API:PATCH:api/users/<id>:min:2026-10-29-23-30 17 ← update user
966
- demo:API:DELETE:api/users/<id>:min:2026-10-29-23-30 2 ← delete user
967
- ```
968
-
969
- Totals:
970
-
971
- ```text
972
- demo:global:min:2026-10-29-23-35 all requests
973
- demo:API:global:min:2026-10-29-23-35 all API requests
974
- demo:WEB:global:min:2026-10-29-23-35 all page views
975
- demo:RPC:global:min:2026-10-29-23-35 all JSON-RPC calls
976
- ```
977
-
978
- Every type has its own `{prefix}:{TYPE}:global` total. `demo:global` counts requests, connections and calls coming into your app. WebSocket messages, Socket.IO events and outgoing calls are left out of it; they are still counted in `demo:WSS:global` and `demo:OUT:global`.
979
-
980
- Failed requests (status 500 or higher) can also get their own key; see [Choose what to track](#choose-what-to-track).
981
-
982
- ### What gets counted
983
-
984
- | TYPE | What | ACTION | Example key |
985
- | --- | --- | --- | --- |
986
- | `API` | JSON / REST endpoints | HTTP method | `demo:API:POST:api/orders:min:…` |
987
- | `WEB` | Pages (HTML responses) | HTTP method | `demo:WEB:GET:blog/<slug>:min:…` |
988
- | `GRAPHQL` | GraphQL operations | `QUERY`, `MUTATION`, `SUBSCRIPTION` | `demo:GRAPHQL:QUERY:GetUser:min:…` |
989
- | `RPC` | JSON-RPC calls, Next.js Server Actions, tRPC procedures | `CALL`, `NOTIFY`, `ACTION`, `QUERY`, `MUTATION`, `SUBSCRIPTION` | `demo:RPC:CALL:eth_getBalance:min:…` |
990
- | `WSS` | WebSocket connections, messages, Socket.IO events | `CONNECT`, `MESSAGE`, `EVENT` | `demo:WSS:CONNECT:ws/chat:min:…` |
991
- | `SSE` | Server-sent event streams | HTTP method | `demo:SSE:GET:events/prices:min:…` |
992
- | `GRPC` | gRPC, gRPC-Web and Connect calls | `CALL` | `demo:GRPC:CALL:user.UserService/GetUser:min:…` |
993
- | `OTHER` | Requests that match no route | HTTP method | `demo:OTHER:GET:<unmatched>:min:…` |
994
- | `STATIC` | JS, CSS, images, fonts — **off by default** | HTTP method | `demo:STATIC:GET:assets/<file>:min:…` |
995
- | `OUT` | Calls your app makes to other services — **off by default** | HTTP method | `demo:OUT:GET:rpc-testnet.supra.com/rpc/v1/block:min:…` |
996
-
997
- How each type is detected, checked in this order (the first match wins):
998
-
999
- 1. **Your own naming**: the `name` option, if it returns a name. See [Name requests yourself](#name-requests-yourself).
1000
- 2. **Custom types**: the path matches one of your `types`. See [Custom types](#custom-types).
1001
- 3. **Operations**: the path matches one of your `operations`. See [One URL, many operations](#one-url-many-operations).
1002
- 4. **Next.js Server Actions** (`RPC`, `ACTION`): a `POST` with a `Next-Action` header. The name is the page it was called from (`/` for the home page), since Next.js gives the action itself a new id on every build.
1003
- 5. **tRPC** (`RPC`): the path ends in `/trpc/{procedure}`, as in `/api/trpc/user.byId`. A `GET` is a `QUERY`, a `POST` a `MUTATION`, and an event stream a `SUBSCRIPTION`. In a batch (`/trpc/a.list,b.count?batch=1`), each procedure is counted.
1004
- 6. **gRPC-Web and Connect** (`GRPC`): a `POST` to `/package.Service/Method` with a `Content-Type` of `application/grpc-web…`, `application/grpc…` or `application/connect+…`, or a `Connect-Protocol-Version` header.
1005
- 7. **`RPC`**: the parsed request body has `"jsonrpc": "2.0"` and a `method`. A call with an `id` is `CALL` and one without is `NOTIFY`. In a batch (`[{...}, {...}]`), each call is counted under its method and in `demo:RPC:global`, while `demo:global` counts the HTTP request once. Repeated methods in a batch are merged into one Redis write per key.
1006
- 8. **`GRAPHQL`**: the parsed request body has a `query` that is a GraphQL document (it starts with `{` or with `query`, `mutation` or `subscription` and contains `{`). The name is `operationName`, else the name in the document, else `<anonymous>`. A REST body like `{"query": "shoes"}` stays `API`. Also recognised: a `GET` with the document in `?query=` (and `?operationName=`), an `application/graphql` body, and a batch (`[{...}, {...}]`), where each operation is counted.
1007
- 9. **`OTHER`**: the response status is 404 and no route matched. All of these share the single name `<unmatched>`, so bot scans can't create new keys.
1008
- 10. **`SSE`**: the response `Content-Type` is `text/event-stream`.
1009
- 11. **`STATIC`**: the file extension is `js`, `mjs`, `css`, `map`, `png`, `jpg`, `jpeg`, `gif`, `svg`, `ico`, `webp`, `avif`, `woff`, `woff2`, `ttf`, `otf`, `eot`, `mp4` or `webm`, or the path starts with `/_next/static/`. The file name becomes `<file>`.
1010
- 12. **`WEB`**: the response `Content-Type` is `text/html` and either the status is below 400 or the request asked for HTML (a browser `Accept: text/html` or `Sec-Fetch-Dest: document` header). A response with no `Content-Type` also counts as `WEB` when the browser asked for a page, for example a `304 Not Modified` revalidation or a page load the user cancelled. An HTML error page returned to a script or API client counts as `API`.
1011
- 13. **`API`**: everything else.
1012
-
1013
- Detected outside this order:
1014
-
1015
- - **`WSS`**:
1016
- - `CONNECT` is counted when a request upgrades to a WebSocket.
1017
- - `MESSAGE` counts each message received through the [`ws`](https://www.npmjs.com/package/ws) library on that connection.
1018
- - `EVENT` counts each [Socket.IO](https://socket.io) event by name. `chat:send` becomes `chat/send`.
1019
- - Socket.IO's own traffic (its polling requests and raw messages) is not counted separately.
1020
- - **`GRPC`**: an HTTP/2 stream with `Content-Type: application/grpc`. The name is the gRPC method path.
1021
- - **`OUT`**: requests made with `http`, `https` or `fetch`. The name is the host, plus `_port` for a non-default port, then the path, with the query string dropped. For example, `127.0.0.1_4000/rpc/v1/block`. The dashboard marks each one as a call to your backend or to a third party (see [Your own domains](#your-own-domains)).
1022
-
1023
- #### Your own domains
1024
-
1025
- Outgoing calls (`track: { OUT: true }`) go either to your own backend or to third parties, and the dashboard shows them apart. A host counts as yours when:
1026
-
1027
- - it's under a domain your app is reached on: a request to `qa.example.com` (or `X-Forwarded-Host: qa.example.com` behind a proxy) makes `qa-api.example.com` and `api.example.com` yours. Country domains such as `example.co.nz` are understood;
1028
- - it's local or private: `localhost`, a name without dots (a Docker or Kubernetes service such as `backend`), `*.local`, `*.internal`, `*.svc`, or a private IP such as `10.0.0.4` or `192.168.1.9`;
1029
- - or it's under one of the `ownDomains` you list, for when your backend is on a different domain, or before your app has served a request:
1030
-
1031
- ```ts
1032
- statis.init({
1033
- redis: "redis://127.0.0.1:6379",
1034
- prefix: "demo",
1035
- track: { OUT: true },
1036
- ownDomains: ["example.com", "example-api.io"], // also matches their subdomains
1037
- });
36
+ References:
37
+ NPM: https://www.npmjs.com/package/system-tracker
38
+ SKILL.md: https://github.com/NPM-public-repo-Jaykumar/system-tracker/blob/main/SKILL.md
1038
39
  ```
1039
40
 
1040
- Either way they stay outgoing calls: they're counted in `demo:OUT:global`, not in `demo:global`. A request your app receives and the call it makes to your backend are one piece of work, and counting both would count it twice. The keys don't change; the dashboard sorts them by host when it shows them.
1041
-
1042
- Always skipped:
1043
-
1044
- - `OPTIONS` and `HEAD` requests (change with `ignore.methods`)
1045
- - Next.js link prefetches (`Next-Router-Prefetch`, `Purpose: prefetch` or `Sec-Purpose: prefetch` headers)
1046
- - Next.js client navigation data (an `RSC: 1` header that isn't a page load, or `/_next/data/...`). [The page-view script](#page-views-in-single-page-apps) counts those page changes
1047
- - Socket.IO long-polling requests (`/socket.io/...`)
1048
- - Next.js's own WebSocket in development (`/_next/webpack-hmr`, used for hot reload)
41
+ ## What gets counted
1049
42
 
1050
- `RPC` and `GRAPHQL` are read from the JSON body: `req.body` when your app's body parser set it, otherwise the body as your app read it (Fastify, Koa, Next.js and plain Node keep theirs elsewhere). system-tracker never reads the body itself; it sees it while your app does, up to 64 KB. A body your app never reads, or a larger one without `req.body`, counts as `API`.
1051
-
1052
- ### How routes are named
1053
-
1054
- The `name` part of a key is the route, without the leading `/`. The home page `/` is named `/`.
1055
-
1056
- With Express (and NestJS, which uses Express), the route you wrote is used:
1057
-
1058
- | Route | Name |
43
+ | Type | What it is |
1059
44
  | --- | --- |
1060
- | `app.get("/users/:userId")` | `users/<userId>` |
1061
- | `router.get("/:id")` mounted at `/api/orders` | `api/orders/<id>` |
1062
- | `app.get("/items/:id?")` (Express 4) or `app.get("/items{/:id}")` (Express 5) | `items/<id>` |
1063
- | `app.get("/files/*")` | `files/<rest>` |
1064
- | `app.get("/files/*splat")` (Express 5) | `files/<splat>` |
1065
-
1066
- A route that's only a wildcard, such as `app.all("*")` or `app.all("/{*splat}")` in a Next.js custom server or Remix and React Router on Express, says nothing about the page, so those requests are named from their URL, as below.
1067
-
1068
- Everywhere else (plain Node, Fastify, Koa, Next.js), the URL is cleaned up:
45
+ | `API` | Calls to your API |
46
+ | `WEB` | Pages people open in a browser |
47
+ | `GRAPHQL` | GraphQL queries and mutations |
48
+ | `RPC` | RPC calls, and anything on an `rpc` address |
49
+ | `WSS` | Live connections (WebSockets, Socket.IO) |
50
+ | `SSE` | Live updates streamed to the browser |
51
+ | `GRPC` | gRPC calls |
52
+ | `OTHER` | Pages that don't exist, bot scans |
53
+ | `STATIC` | Images, scripts and styles (off until you turn it on) |
54
+ | `OUT` | Calls your app makes to other services (off until you turn it on) |
1069
55
 
1070
- | URL | Name | Why |
1071
- | --- | --- | --- |
1072
- | `/api/users/42` | `api/users/<id>` | Numbers become `<id>` |
1073
- | `/api/users/550e8400-e29b-41d4-a716-446655440000` | `api/users/<id>` | UUIDs become `<id>` |
1074
- | `/api/items/507f1f77bcf86cd799439011` | `api/items/<id>` | MongoDB ObjectIds become `<id>` |
1075
- | `/tx/deadbeefdeadbeef` | `tx/<id>` | Hex strings of 16+ characters become `<id>` |
1076
- | `/accounts/0x1a2b3c4d5e6f/balance` | `accounts/<id>/balance` | `0x` addresses (8+ hex characters) become `<id>` |
1077
- | `/reset/aB3dE5gH7jK9mN1pQ3rS` | `reset/<id>` | Tokens of 20+ letters/digits mixing both become `<id>` |
1078
- | `/api/search?q=eth&page=2` | `api/search` | Query string dropped |
1079
- | `/api/users/42/` | `api/users/<id>` | Trailing slash dropped |
1080
- | `/v1/things:batchGet` | `v1/things/batchGet` | `:` becomes `/` |
1081
- | `/files/<a 100-character name>` | `files/<slug>` | Parts longer than 64 characters become `<slug>` |
1082
- | `/a/b/c/…` (more than 16 parts) | first 16 parts + `/<rest>` | Very deep URLs are cut |
1083
- | `/blog/hello-world` | `blog/hello-world`, later `blog/<slug>` | See below |
56
+ The dashboard and its own pages are never counted.
1084
57
 
1085
- Words like `hello-world` don't look like IDs. Once one position in a URL has seen more than 50 different values, every value there is counted as `<slug>`. This keeps blog posts, usernames and similar pages from creating keys forever. See [Limits](#limits).
58
+ ## Works with
1086
59
 
1087
- URL parts are learned only from responses with a status below 400. An error response (401, 403, 500, …) to a URL part that has never had a successful response is named `<slug>`. This stops bots probing random URLs from filling your names or collapsing real routes. Express routes are always named from the route itself, whatever the status.
60
+ Express, NestJS, Fastify, Koa and plain Node, Next.js, Nuxt, SvelteKit, Remix and React Router, Astro, and single-page apps built with React, Vue, Angular or Svelte.
1088
61
 
1089
- The URL is read as it arrived, so routes inside mounted routers (`app.use("/api", router)`) keep their full path for naming, `ignore.paths`, `types` and `operations`.
62
+ ## Learn more
1090
63
 
1091
- Placeholders you may see in names:
64
+ - [Set up your framework](guide/frameworks.md): where to add it, and counting page changes in single-page apps
65
+ - [Dashboard](guide/dashboard.md): overview, insights, boards and recent requests
66
+ - [Settings](guide/settings.md): connecting to Redis, what to track or skip, and how long counts are kept
67
+ - [How it works](guide/how-it-works.md): how each request is sorted, how routes are named, and reading the numbers in your own code
1092
68
 
1093
- | Placeholder | Meaning |
1094
- | --- | --- |
1095
- | `<id>` | A number, UUID, ObjectId, long hex string, `0x` address or long token |
1096
- | `<paramName>` | An Express route parameter, e.g. `<userId>` |
1097
- | `<rest>` | An Express wildcard (`*`), or everything after the 16th part of a URL |
1098
- | `<slug>` | A URL part with too many different values, longer than 64 characters, or never seen in a successful response |
1099
- | `<file>` | A static file name |
1100
- | `<unmatched>` | A request that matched no route |
1101
- | `<other>` | A name beyond the per-type limit |
1102
- | `<anonymous>` | A GraphQL operation without a name |
1103
- | `<unknown>` | An operation name your `read` function could not find |
1104
-
1105
- Names never contain `:` (it is replaced with `/`) and are cut at 200 characters, so every key splits cleanly on `:`.
1106
-
1107
- ### How it behaves
1108
-
1109
- - **It never breaks your app.** Tracking errors are logged as `[system-tracker] … failed` and never thrown into your request handling. Response bodies and status codes are untouched.
1110
- - **Request bodies.** JSON bodies are watched as your app reads them, up to 64 KB, for GraphQL and JSON-RPC. With the dashboard on, other text bodies are too, for [recent requests](#recent-requests-and-failures). Nothing is read ahead of your app, and file uploads and binary bodies are skipped.
1111
- - **Quiet when Redis fails.** If Redis keeps returning errors (out of memory, read-only after a failover), the same error is logged at most once every 10 seconds, with a `suppressed` count of the ones skipped.
1112
- - **Redis cost.** Each key is one `EVALSHA` per enabled bucket (the script is sent once, then called by its hash). Repeated hits in one request are merged with `INCRBY`.
1113
- - **Redis down or still connecting.** Automatic counts are skipped until Redis is ready, so nothing piles up in memory. Requests that arrive in the first moments after startup, before the connection is up, are not counted.
1114
- - **When a request is counted.** When its response finishes, or when the client disconnects. A request the client aborts is counted once. The bucket is the time the request arrived; an SSE stream is counted when it closes, under the time it opened.
1115
- - **What isn't counted.** Only work that arrives as a request is counted (HTTP, WebSockets, Socket.IO, SSE, gRPC), plus outgoing calls with `track: { OUT: true }`. Cron jobs, queue workers and scripts that never receive a request aren't counted.
1116
- - **Calling `init` twice.** The second call is ignored and logs an error.
1117
- - **Invalid options.** An empty `prefix`, a `prefix` containing `:`, or an invalid `expiry_in_sec` makes `init` throw before it connects to Redis.
1118
- - **WebSocket and Socket.IO libraries.** `ws` and `socket.io` are picked up from your app's own `node_modules`. They are not dependencies of this package.
1119
- - **Redis Cluster.** Keys never contain `{` or `}`, so they spread across all cluster nodes.
1120
- - **Keys stay bounded.** IDs are replaced, unknown URLs share `<unmatched>`, and [limits](#limits) cap the rest.
1121
- - **Shared totals.** Keep the same `prefix` across services to get one combined `global` total. A different `prefix` gives a separate set of keys.
1122
- - **Tested with** Node.js 22, ioredis 5, Express 5, ws 8 and Socket.IO 4.
1123
-
1124
- ## More
1125
-
1126
- ### Examples
1127
-
1128
- - [examples/express-ts/server.ts](examples/express-ts/server.ts): Express + TypeScript with `statis.init` and a `/api/statistics` endpoint.
1129
- - [examples/express-js/server.js](examples/express-js/server.js): the same in JavaScript.
1130
- - [html-rpc-calls-demo](html-rpc-calls-demo): an HTML page calling Supra testnet RPC endpoints. The outgoing calls are counted with `track: { OUT: true }`; the handlers have no tracking code.
1131
-
1132
- ### TypeScript
1133
-
1134
- Types are included:
1135
-
1136
- ```ts
1137
- import type {
1138
- InitOptions, // options for statis.init
1139
- DashboardOptions, // the dashboard option of statis.init
1140
- Hit, // { type, action, name } returned by the name option
1141
- BuiltInType, // "API" | "WEB" | "GRAPHQL" | "RPC" | "WSS" | "SSE" | "GRPC" | "OTHER" | "STATIC" | "OUT"
1142
- RedisOption, // the redis option of statis.init
1143
- TimeConfig, // timeConfig
1144
- TimeBucket, // "sec" | "min" | "hour" | "day" | "month" | "year"
1145
- TimeBucketConfig,
1146
- } from "system-tracker";
1147
- ```
69
+ ## Try it
1148
70
 
1149
- ### Demo
71
+ - Online demo: <a href="https://stackblitz.com/edit/stackblitz-starters-vfzwhow5?file=server.ts" target="_blank" rel="noopener noreferrer">TypeScript</a> · <a href="https://stackblitz.com/edit/stackblitz-starters-akuiwtmr?file=server.js" target="_blank" rel="noopener noreferrer">JavaScript</a>
72
+ - [Express + TypeScript](examples/express-ts/server.ts) and [Express + JavaScript](examples/express-js/server.js): setup plus a `/api/statistics` endpoint that reads the counts
73
+ - [A page calling RPC endpoints](html-rpc-calls-demo): counts the calls your server makes to them, with `track: { OUT: true }` and no tracking code in the handlers
1150
74
 
1151
- - TS StackBlitz: <a href="https://stackblitz.com/edit/stackblitz-starters-vfzwhow5?file=server.ts" target="_blank" rel="noopener noreferrer">Open TS demo</a>
1152
- - JS StackBlitz: <a href="https://stackblitz.com/edit/stackblitz-starters-akuiwtmr?file=server.js" target="_blank" rel="noopener noreferrer">Open JS demo</a>
75
+ If `npm install` stops with `ERESOLVE`, the clash is between other packages in your project (system-tracker has no peer dependencies). Add `--legacy-peer-deps`. TypeScript types are included; see [the list](guide/settings.md#typescript).