analytics-platform 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +247 -2
- package/index.d.ts +184 -0
- package/index.js +124 -0
- package/lib/analytics.js +105 -0
- package/lib/audit.js +57 -0
- package/lib/bans.js +66 -0
- package/lib/core.js +122 -0
- package/lib/crawlers.js +211 -0
- package/lib/errors.js +165 -0
- package/lib/firewall.js +262 -0
- package/lib/flags.js +41 -0
- package/lib/forms.js +81 -0
- package/lib/headers.js +22 -0
- package/lib/inventory.js +142 -0
- package/lib/ip.js +104 -0
- package/lib/maintenance.js +67 -0
- package/lib/next.js +249 -0
- package/lib/performance.js +115 -0
- package/lib/posture.js +224 -0
- package/lib/proxy.js +191 -0
- package/lib/requestRules.js +145 -0
- package/lib/resources.js +453 -0
- package/next.d.ts +36 -0
- package/package.json +41 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 steviec87
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,248 @@
|
|
|
1
|
-
#
|
|
1
|
+
# analytics-platform
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One package for your Node server, run from your analytics dashboard: a
|
|
4
|
+
**firewall**, **error monitoring** and **server analytics** (payments,
|
|
5
|
+
sign-ups, leads). Turn modules on or off; the ones you turn off aren't loaded.
|
|
6
|
+
No dependencies; Node 18 or later.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install analytics-platform
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
const { createPlatform } = require("analytics-platform");
|
|
14
|
+
|
|
15
|
+
const platform = createPlatform({
|
|
16
|
+
siteId: "YOUR_SITE_ID",
|
|
17
|
+
secret: process.env.ANALYTICS_SITE_SECRET, // site settings; keep it on the server
|
|
18
|
+
endpoint: "https://YOUR_COLLECTOR_URL",
|
|
19
|
+
// trustProxy: 1, // behind Nginx or a load balancer
|
|
20
|
+
// trustProxy: "cloudflare", // every request through Cloudflare
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.use(platform.middleware()); // before your routes
|
|
24
|
+
|
|
25
|
+
// ...your routes...
|
|
26
|
+
|
|
27
|
+
app.use(platform.errorHandler()); // after your routes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Modules
|
|
31
|
+
|
|
32
|
+
| Module | What it does | Default |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `firewall` | Blocks addresses, known criminal networks, shared blocks and floods (Security → Firewall) | on |
|
|
35
|
+
| `firewall`'s attack patterns | Rules that match the *request* (its path, query string and user agent), not just the address, so an attack is turned away before your code runs — a package with a known hole stays safe until you update it. They come with the firewall's rules and are managed on the Security page (Firewall → Attack patterns); a pattern a real visitor might trip only watches until you promote it. Nothing to configure (protocol: `sdk/protocols/firewall.md`) | with the firewall |
|
|
36
|
+
| `proxy` | Serves the tracker and takes visits under your own domain, so ad blockers can't hide those visitors | on |
|
|
37
|
+
| `errors` | Sends your server's errors to the Errors report | on |
|
|
38
|
+
| `analytics` | `track()`, `identify()` and `lead()` from your server | on |
|
|
39
|
+
| `performance` | Response times and errors of every route (Health → Server speed) | on |
|
|
40
|
+
| `crawlers` | Which search engines and AI bots fetch your pages (Health → Crawlers). Your tracker can't see them: crawlers don't run JavaScript | on |
|
|
41
|
+
| `audit` | `platform.audit("user.role_changed", { actor, target, details, req })` (Security → Audit log) | on |
|
|
42
|
+
| `flags` | `platform.isEnabled("new-checkout", { user: user.id })`: features on or off from the dashboard | on |
|
|
43
|
+
| `maintenance` | A "back soon" page while maintenance mode is on in the dashboard | on |
|
|
44
|
+
| `headers` | Adds the security headers you pick on the Security page; your own headers win | on |
|
|
45
|
+
| `forms` | Checks forms before you accept them: `platform.protectForm()` / `platform.checkForm(req)` | on |
|
|
46
|
+
| `inventory` | Your production packages (`package-lock.json`) and Node version, once a day, so the Security check can flag known security holes (`{ dir }`: the folder with the lock file) | on |
|
|
47
|
+
| `posture` | How the server is set up, once a day, for the Security check: `NODE_ENV`, whether the app runs as root, code anyone on the machine can write, and files in the folders you serve to the web that shouldn't be public (a dump, a `.env`, a `.bak`). The dashboard then asks your site for each of those files and tells you whether it really is downloadable. Reads only, and sends no file's contents. `{ roots: ["public", { dir: "uploads", prefix: "files" }] }` (default: `./public` and `./static` when they exist; `prefix` is where the web serves the folder), `{ checks }` for checks of your own (protocol: `sdk/protocols/posture.md`) | on |
|
|
48
|
+
|
|
49
|
+
Each takes `true`, `false` or its options:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
createPlatform({
|
|
53
|
+
siteId, secret, endpoint,
|
|
54
|
+
firewall: { mode: "monitor", cacheFile: "./firewall-rules.json" },
|
|
55
|
+
errors: { captureUncaught: false },
|
|
56
|
+
analytics: false,
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Switch modules off in the dashboard** (Security → Settings → Server package) without a
|
|
61
|
+
deploy: the package checks about once a minute. A module turned off in your
|
|
62
|
+
code isn't loaded at all, so the dashboard can't turn it on.
|
|
63
|
+
|
|
64
|
+
Modules are independent: one failing to start, or the collector being down,
|
|
65
|
+
never stops the others, and never slows your requests. The firewall checks
|
|
66
|
+
requests against rules it keeps in memory; everything is sent in the
|
|
67
|
+
background, in batches.
|
|
68
|
+
|
|
69
|
+
## Firewall
|
|
70
|
+
|
|
71
|
+
Rules, monitor or block mode and reports are in the dashboard (Security →
|
|
72
|
+
Firewall). New sites start in monitor mode: nothing is blocked until you switch.
|
|
73
|
+
|
|
74
|
+
| Option | |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `mode` | `"monitor"` or `"block"`; overrides the dashboard |
|
|
77
|
+
| `skip(req)` | return `true` to leave a request unchecked (health checks) |
|
|
78
|
+
| `onBlock(req, res, decision)` | send your own response |
|
|
79
|
+
| `cacheFile` | keep the last rules between restarts |
|
|
80
|
+
| `trustProxy` | overrides the platform's |
|
|
81
|
+
|
|
82
|
+
**The visitor's address:** behind a proxy, every request comes from the proxy
|
|
83
|
+
unless you set `trustProxy`:
|
|
84
|
+
|
|
85
|
+
| Setup | Option |
|
|
86
|
+
|---|---|
|
|
87
|
+
| Express with `app.set("trust proxy", ...)` | nothing (uses `req.ip`) |
|
|
88
|
+
| One proxy (Nginx, a load balancer) | `trustProxy: 1` |
|
|
89
|
+
| Cloudflare → load balancer | `trustProxy: 2` |
|
|
90
|
+
| Every request through Cloudflare | `trustProxy: "cloudflare"` |
|
|
91
|
+
|
|
92
|
+
**Bot protection:** call `platform.loginFailed(req)` when a login fails. Past
|
|
93
|
+
the site's limit the address is banned here straight away and on your other
|
|
94
|
+
servers within a minute or two. Floods (over the rate limit) are banned the
|
|
95
|
+
same way, and the dashboard can ban visits the tracker scores high risk.
|
|
96
|
+
|
|
97
|
+
Other frameworks: `platform.firewall.check({ ip, method, path, userAgent })`
|
|
98
|
+
returns the decision.
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
// Fastify
|
|
102
|
+
fastify.addHook("onRequest", async (req, reply) => {
|
|
103
|
+
const d = platform.firewall.check({ ip: req.ip, method: req.method, path: req.url, userAgent: req.headers["user-agent"] });
|
|
104
|
+
if (d.action === "block") return reply.code(d.status).send("Forbidden");
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
**Probes.** Requests for files no visitor ever asks for (`/.env`, `/.git/…`,
|
|
110
|
+
backups: the list comes with the rules) are rule `probe`; in block mode the
|
|
111
|
+
address is also banned on this server, and the collector bans it on the
|
|
112
|
+
site's other servers.
|
|
113
|
+
## Error monitoring
|
|
114
|
+
|
|
115
|
+
Uncaught exceptions and unhandled rejections are sent on their own; your
|
|
116
|
+
process still exits as it would. `errorHandler()` sends 5xx errors from your
|
|
117
|
+
routes. Errors you catch:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
try {
|
|
121
|
+
await chargeCard(order);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
platform.captureException(error, { req, status: 502 });
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Options: `serverName`, `root` (stack paths relative to it), `captureUncaught`,
|
|
129
|
+
`beforeSend(event, error)` (change an error, or return `null` to drop it).
|
|
130
|
+
|
|
131
|
+
## Server analytics
|
|
132
|
+
|
|
133
|
+
For what only your server knows for sure. Not pageviews: the tracker counts
|
|
134
|
+
those.
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
// A payment
|
|
138
|
+
platform.track("purchase", { numeric_value: "49.90", currency: "EUR", event_uid: `order-${order.id}` }, { req });
|
|
139
|
+
|
|
140
|
+
// Link the visit to your user
|
|
141
|
+
await platform.identify(user.id, { req });
|
|
142
|
+
|
|
143
|
+
// Add or update a lead
|
|
144
|
+
await platform.lead({ email: "ana@example.com", name: "Ana", status: "qualified" });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Link to the visit:** send the tracker's session ID with your site's requests,
|
|
148
|
+
and pass `{ req }`:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
fetch("/api/orders", { headers: { "x-analytics-session": window.analyticsSession?.getId() || "" }, ... });
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Next.js and Vercel
|
|
155
|
+
|
|
156
|
+
The firewall in Next.js's proxy (`proxy.ts`, or `middleware.ts` before Next.js
|
|
157
|
+
16), which runs on the edge too, and server errors from `instrumentation.ts`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// proxy.ts
|
|
161
|
+
import { createNextFirewall } from "analytics-platform/next";
|
|
162
|
+
export const proxy = createNextFirewall({ siteId, secret: process.env.ANALYTICS_SITE_SECRET!, endpoint });
|
|
163
|
+
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
164
|
+
|
|
165
|
+
// instrumentation.ts
|
|
166
|
+
import { createOnRequestError } from "analytics-platform/next";
|
|
167
|
+
export const onRequestError = createOnRequestError({ siteId, secret: process.env.ANALYTICS_SITE_SECRET!, endpoint });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
On Vercel the visitor's address is found for you; elsewhere set `trustProxy`.
|
|
171
|
+
Each instance keeps the rules in memory and counts the rate limit on its own:
|
|
172
|
+
pass `counter: async (key, ttl) => hits` (e.g. Upstash Redis) for one count.
|
|
173
|
+
For events from route handlers and server actions, use `createPlatform` there.
|
|
174
|
+
|
|
175
|
+
## Crawlers and AI bots
|
|
176
|
+
|
|
177
|
+
`platform.middleware()` counts every request from a crawler — `GPTBot`,
|
|
178
|
+
`ClaudeBot`, `ChatGPT-User`, `PerplexityBot`, `Googlebot`, `bingbot`, the SEO
|
|
179
|
+
tools, the link previews — and sends one small summary a minute per bot, page
|
|
180
|
+
and status. See it under Health → Crawlers.
|
|
181
|
+
|
|
182
|
+
Your browser tracker cannot see any of this: crawlers don't run JavaScript. So
|
|
183
|
+
this is the only place you learn that an AI fetched a page because someone
|
|
184
|
+
asked it about you, that Googlebot has been asking for a page you deleted, or
|
|
185
|
+
that something calling itself Googlebot isn't.
|
|
186
|
+
|
|
187
|
+
Nothing about your visitors is counted or sent: a request is counted only when
|
|
188
|
+
its user agent names a crawler. Requests the firewall turned away are counted
|
|
189
|
+
too, with the status the crawler got.
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
createPlatform({
|
|
193
|
+
siteId, secret, endpoint,
|
|
194
|
+
crawlers: { serverName: "web-1" }, // or false to leave them uncounted
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Crawler addresses go with each batch so the collector can check a crawler is
|
|
199
|
+
itself (Google, Bing, Yandex, Baidu and Apple publish a reverse-DNS
|
|
200
|
+
convention; for the rest the network is shown). No address is stored. The
|
|
201
|
+
protocol is `sdk/protocols/crawlers.md`.
|
|
202
|
+
|
|
203
|
+
Other frameworks: `platform.crawlers.record({ bot, path, status, bytes, ms, ip })`,
|
|
204
|
+
with `platform.crawlers.detect(userAgent)` for the bot's name (`""` when the
|
|
205
|
+
request isn't from one).
|
|
206
|
+
|
|
207
|
+
## Performance & API monitoring
|
|
208
|
+
|
|
209
|
+
`platform.middleware()` times every request it lets through and sends one
|
|
210
|
+
small summary a minute per route (Express route patterns like `/users/:id`;
|
|
211
|
+
elsewhere numbers and IDs in the path become `:id`). See it under Health →
|
|
212
|
+
Server speed. Name routes yourself with `performance: { route: (req) => "..." }`.
|
|
213
|
+
|
|
214
|
+
## Ad blockers
|
|
215
|
+
|
|
216
|
+
Many visitors use an ad blocker, and most blockers stop analytics. They
|
|
217
|
+
recognise **the address a script comes from** and **what it is called**, so a
|
|
218
|
+
tracker loaded from us is simply never counted.
|
|
219
|
+
|
|
220
|
+
This package already runs on your own domain, so it serves the tracker and
|
|
221
|
+
takes the visits itself. Nothing to set up: use the two addresses it gives you
|
|
222
|
+
in your page's script tag.
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
app.use(platform.middleware()); // the proxy answers before anything else
|
|
226
|
+
|
|
227
|
+
// in your template
|
|
228
|
+
`<script async src="${platform.proxy.scriptUrl()}"
|
|
229
|
+
data-siteid="${siteId}" data-domain="example.com"
|
|
230
|
+
data-collect="${platform.proxy.collectUrl()}"></script>`
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- The path comes from your secret (`/3f9a2c1b/...`), so it isn't in the page
|
|
234
|
+
and isn't a name any blocker lists. Pick your own with `proxy: { prefix }`.
|
|
235
|
+
- Tracker files are fetched once and then served from memory; if we can't be
|
|
236
|
+
reached, the copy already held is served rather than nothing.
|
|
237
|
+
- The visitor's address goes on as `X-Forwarded-For` (your collector needs it
|
|
238
|
+
for the country, the daily visitor ID and the rate limit) and cookies never
|
|
239
|
+
leave your server.
|
|
240
|
+
- Beacons are answered before the firewall, the crawler count and the timing,
|
|
241
|
+
so your own analytics traffic doesn't appear as pages of your site.
|
|
242
|
+
- Switch it off with `proxy: false`, or in the dashboard (Site settings →
|
|
243
|
+
Server). Then load the tracker from us as before.
|
|
244
|
+
|
|
245
|
+
## Shutting down and serverless
|
|
246
|
+
|
|
247
|
+
`await platform.flush()` sends everything waiting (on Vercel or Lambda, before
|
|
248
|
+
returning). `await platform.close()` also stops fetching rules.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
export type TrustProxy = boolean | number | "cloudflare" | ((req: IncomingMessage) => string);
|
|
4
|
+
|
|
5
|
+
export interface FirewallDecision {
|
|
6
|
+
action: "allow" | "block" | "monitor";
|
|
7
|
+
/** Empty when allowed. */
|
|
8
|
+
rule: "deny" | "ban" | "list" | "shared" | "rate" | "";
|
|
9
|
+
/** 403, or 429 for the rate limit. */
|
|
10
|
+
status: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FirewallOptions {
|
|
14
|
+
/** Overrides the platform's trustProxy. */
|
|
15
|
+
trustProxy?: TrustProxy;
|
|
16
|
+
/** Overrides the mode set in the dashboard. */
|
|
17
|
+
mode?: "monitor" | "block";
|
|
18
|
+
/** Return true to leave a request unchecked. */
|
|
19
|
+
skip?: (req: IncomingMessage) => boolean;
|
|
20
|
+
/** Send your own response for blocked requests. */
|
|
21
|
+
onBlock?: (req: IncomingMessage, res: ServerResponse, decision: FirewallDecision) => void;
|
|
22
|
+
/** Keeps the last rules on disk, so they apply straight after a restart. */
|
|
23
|
+
cacheFile?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ErrorsOptions {
|
|
27
|
+
/** Default: the host name. */
|
|
28
|
+
serverName?: string;
|
|
29
|
+
/** Paths in stacks are made relative to it. Default: process.cwd(). */
|
|
30
|
+
root?: string;
|
|
31
|
+
/** Capture uncaught exceptions and unhandled rejections. Default true. */
|
|
32
|
+
captureUncaught?: boolean;
|
|
33
|
+
/** Change an error before it's sent, or return null to drop it. */
|
|
34
|
+
beforeSend?: (event: Record<string, unknown>, error: unknown) => Record<string, unknown> | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PlatformOptions {
|
|
38
|
+
siteId: string;
|
|
39
|
+
/** The site secret (site settings). Keep it on the server. */
|
|
40
|
+
secret: string;
|
|
41
|
+
/** The collector's URL. */
|
|
42
|
+
endpoint: string;
|
|
43
|
+
/**
|
|
44
|
+
* How to find the visitor's address. Default: Express's req.ip, else the
|
|
45
|
+
* connection's address. A number trusts that many proxies in
|
|
46
|
+
* X-Forwarded-For (true = 1); "cloudflare" reads CF-Connecting-IP.
|
|
47
|
+
*/
|
|
48
|
+
trustProxy?: TrustProxy;
|
|
49
|
+
/** Logs send problems. */
|
|
50
|
+
debug?: boolean;
|
|
51
|
+
/** On by default. */
|
|
52
|
+
firewall?: boolean | FirewallOptions;
|
|
53
|
+
/** On by default. */
|
|
54
|
+
errors?: boolean | ErrorsOptions;
|
|
55
|
+
/** On by default. */
|
|
56
|
+
analytics?: boolean;
|
|
57
|
+
/** Security headers picked on the dashboard. On by default (adds none until you pick some). */
|
|
58
|
+
headers?: boolean;
|
|
59
|
+
/** Feature flags. On by default. */
|
|
60
|
+
flags?: boolean;
|
|
61
|
+
/** Maintenance mode. On by default (does nothing until you turn it on in the dashboard). */
|
|
62
|
+
maintenance?: boolean | { skip?: (req: IncomingMessage) => boolean };
|
|
63
|
+
/** Audit log. On by default. */
|
|
64
|
+
audit?: boolean;
|
|
65
|
+
/** Form protection. On by default. */
|
|
66
|
+
forms?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Software inventory: your production packages (package-lock.json) and Node
|
|
69
|
+
* version, sent once a day for the Security check. On by default.
|
|
70
|
+
*/
|
|
71
|
+
inventory?: boolean | {
|
|
72
|
+
/** The folder with package-lock.json. Default: the working directory. */
|
|
73
|
+
dir?: string;
|
|
74
|
+
/** The server's name. Default: the host name. */
|
|
75
|
+
host?: string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* How the server is set up, sent once a day for the Security check: whether
|
|
79
|
+
* the app runs in production and as root, code anyone on the machine can
|
|
80
|
+
* write, and files in the folders you serve to the web that shouldn't be
|
|
81
|
+
* public (a dump, a .env, a backup copy). Reads only; no file's contents are
|
|
82
|
+
* sent. On by default; with no served folder, only the settings are reported.
|
|
83
|
+
*/
|
|
84
|
+
posture?: boolean | {
|
|
85
|
+
/** The folders the web serves. Default: ./public and ./static when they exist. */
|
|
86
|
+
roots?: (string | { dir: string; prefix?: string })[];
|
|
87
|
+
/** The server's name. Default: the host name. */
|
|
88
|
+
host?: string;
|
|
89
|
+
/** More checks of your own: { id, state: "pass" | "fail" | "unknown", value? }. */
|
|
90
|
+
checks?: () => Promise<{ id: string; state: string; value?: string }[]> | { id: string; state: string; value?: string }[];
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* First-party proxy: serve the tracker and take visits under this site's
|
|
94
|
+
* own domain, so ad blockers have no address or filename of ours to
|
|
95
|
+
* recognise. On by default; the page's script tag uses proxy.scriptUrl()
|
|
96
|
+
* and proxy.collectUrl().
|
|
97
|
+
*/
|
|
98
|
+
proxy?: boolean | {
|
|
99
|
+
/** The path it answers on. Default: eight characters from the secret. */
|
|
100
|
+
prefix?: string;
|
|
101
|
+
};
|
|
102
|
+
/** Performance & API monitoring. On by default. */
|
|
103
|
+
performance?: boolean | {
|
|
104
|
+
serverName?: string;
|
|
105
|
+
/** Your own route name for a request ("" to use the default). */
|
|
106
|
+
route?: (req: IncomingMessage) => string;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface EventFields {
|
|
111
|
+
value?: string;
|
|
112
|
+
numeric_value?: string | number;
|
|
113
|
+
currency?: string;
|
|
114
|
+
url?: string;
|
|
115
|
+
event_label?: string;
|
|
116
|
+
variant?: string;
|
|
117
|
+
id?: string;
|
|
118
|
+
session_id?: string;
|
|
119
|
+
timestamp?: string;
|
|
120
|
+
/** One occurrence: reuse it when sending the same occurrence again. */
|
|
121
|
+
event_uid?: string;
|
|
122
|
+
[key: string]: unknown;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface WithRequest {
|
|
126
|
+
/** The request, to link to the visit (x-analytics-session header). */
|
|
127
|
+
req?: IncomingMessage;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface Platform {
|
|
131
|
+
/** The modules that are on. */
|
|
132
|
+
modules: string[];
|
|
133
|
+
/** The firewall module, for check() in frameworks without middleware. */
|
|
134
|
+
firewall: {
|
|
135
|
+
check(input: { ip: string; method?: string; path?: string; userAgent?: string }): FirewallDecision;
|
|
136
|
+
clientIp(req: IncomingMessage): string;
|
|
137
|
+
} | null;
|
|
138
|
+
/**
|
|
139
|
+
* The first-party proxy's two addresses, on this site's own domain, for the
|
|
140
|
+
* script tag: <script src={scriptUrl()} data-collect={collectUrl()} ...>.
|
|
141
|
+
*/
|
|
142
|
+
proxy: {
|
|
143
|
+
/** The path it answers on. */
|
|
144
|
+
prefix: string;
|
|
145
|
+
/** The tracker's address, e.g. "/3f9a2c1b/js/v1/s.js". */
|
|
146
|
+
scriptUrl(version?: string): string;
|
|
147
|
+
/** Where visits are sent, e.g. "/3f9a2c1b/api/e". */
|
|
148
|
+
collectUrl(): string;
|
|
149
|
+
/** Whether it is on (it can also be switched off in the dashboard). */
|
|
150
|
+
enabled(): boolean;
|
|
151
|
+
} | null;
|
|
152
|
+
/** Before your routes. */
|
|
153
|
+
middleware(): (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void;
|
|
154
|
+
/** After your routes. Captures 5xx errors and passes them on. */
|
|
155
|
+
errorHandler(options?: { shouldCapture?: (error: unknown, req: IncomingMessage) => boolean }):
|
|
156
|
+
(error: unknown, req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
|
|
157
|
+
/** Checks a form before you accept it (form protection). Spam goes to Leads → Spam. */
|
|
158
|
+
checkForm(req: IncomingMessage | null, options?: { fields?: Record<string, unknown>; email?: string; name?: string; form?: string }):
|
|
159
|
+
Promise<{ ok: boolean; spam: boolean; reasons: string[]; checked: boolean }>;
|
|
160
|
+
/** Express middleware for a form route (after your body parser): spam gets a 422. */
|
|
161
|
+
protectForm(options?: { email?: string; name?: string; form?: string; onSpam?: (req: IncomingMessage, res: ServerResponse, result: { reasons: string[] }) => void }):
|
|
162
|
+
(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void;
|
|
163
|
+
/** A feature flag from the dashboard. user: for rollouts and user lists; default: until it's known (false). */
|
|
164
|
+
isEnabled(key: string, options?: { user?: string | number; default?: boolean }): boolean;
|
|
165
|
+
/** Records who did what (Security → Audit log). action: e.g. "user.role_changed". */
|
|
166
|
+
audit(action: string, options?: { actor?: string; target?: string; details?: unknown; req?: IncomingMessage }): void;
|
|
167
|
+
/** A failed login on your site (bot protection): the request, or the address. */
|
|
168
|
+
loginFailed(reqOrIp: IncomingMessage | string): void;
|
|
169
|
+
captureException(error: unknown, context?: { req?: IncomingMessage; status?: number; kind?: "handled" | "error" | "rejection" }): void;
|
|
170
|
+
/** Returns the event's event_uid ("" when not queued). */
|
|
171
|
+
track(name: string, fields?: EventFields, options?: WithRequest): string;
|
|
172
|
+
/** Links the visit to your user ID. */
|
|
173
|
+
identify(userId: string, options?: WithRequest & { session_id?: string }): Promise<boolean>;
|
|
174
|
+
/** Adds or updates a lead. */
|
|
175
|
+
lead(fields: Record<string, unknown>, options?: WithRequest): Promise<{ status: string; lead_id: string } | null>;
|
|
176
|
+
/** Resolves once each module that fetches something has tried once. */
|
|
177
|
+
ready: Promise<void>;
|
|
178
|
+
/** Sends everything waiting (serverless: before returning). */
|
|
179
|
+
flush(): Promise<void>;
|
|
180
|
+
/** Stops background work and sends what's waiting. */
|
|
181
|
+
close(): Promise<void>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function createPlatform(options: PlatformOptions): Platform;
|
package/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// One package for a site's Node server, run from the analytics dashboard.
|
|
2
|
+
// Each module (lib/) is separate: only the ones turned on are loaded, and one
|
|
3
|
+
// failing never stops the others; the firewall in particular never waits on
|
|
4
|
+
// reporting. Every module shares the core's credentials and sending.
|
|
5
|
+
//
|
|
6
|
+
// const platform = createPlatform({ siteId, secret, endpoint });
|
|
7
|
+
// app.use(platform.middleware()); // before your routes
|
|
8
|
+
// ...routes...
|
|
9
|
+
// app.use(platform.errorHandler()); // after your routes
|
|
10
|
+
//
|
|
11
|
+
// A module is on with `true` (or its options as an object) and off with
|
|
12
|
+
// `false`; one that's loaded can also be switched off in the dashboard (Site
|
|
13
|
+
// settings → Server). No dependencies; Node 18+ (global fetch).
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const { createCore } = require("./lib/core.js");
|
|
17
|
+
|
|
18
|
+
// Module name -> [loader, on by default].
|
|
19
|
+
const MODULES = {
|
|
20
|
+
firewall: [(core, opts) => require("./lib/firewall.js").createFirewall(core, opts), true],
|
|
21
|
+
errors: [(core, opts) => require("./lib/errors.js").createErrors(core, opts), true],
|
|
22
|
+
analytics: [(core) => require("./lib/analytics.js").createAnalytics(core), true],
|
|
23
|
+
performance: [(core, opts) => require("./lib/performance.js").createPerformance(core, opts), true],
|
|
24
|
+
crawlers: [(core, opts) => require("./lib/crawlers.js").createCrawlers(core, opts), true],
|
|
25
|
+
forms: [(core, opts) => require("./lib/forms.js").createForms(core, opts), true],
|
|
26
|
+
headers: [(core) => require("./lib/headers.js").createHeaders(core), true],
|
|
27
|
+
audit: [(core, opts) => require("./lib/audit.js").createAudit(core, opts), true],
|
|
28
|
+
flags: [(core) => require("./lib/flags.js").createFlags(core), true],
|
|
29
|
+
maintenance: [(core, opts) => require("./lib/maintenance.js").createMaintenance(core, opts), true],
|
|
30
|
+
inventory: [(core, opts) => require("./lib/inventory.js").createInventory(core, opts), true],
|
|
31
|
+
posture: [(core, opts) => require("./lib/posture.js").createPosture(core, opts), true],
|
|
32
|
+
resources: [(core, opts) => require("./lib/resources.js").createResources(core, opts), true],
|
|
33
|
+
proxy: [(core, opts) => require("./lib/proxy.js").createProxy(core, opts), true],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Options: siteId, secret (site settings; keep it on the server), endpoint
|
|
37
|
+
// (the collector's URL); trustProxy (how to find the visitor's address, for
|
|
38
|
+
// every module that needs it: see README); debug (log send problems); and
|
|
39
|
+
// per module, true / false / { options }.
|
|
40
|
+
function createPlatform(options = {}) {
|
|
41
|
+
const core = createCore(options);
|
|
42
|
+
if (!core.valid) console.warn("[analytics-platform] siteId, secret and endpoint are required; nothing is sent and every request passes");
|
|
43
|
+
|
|
44
|
+
const modules = {};
|
|
45
|
+
for (const [name, [load, onByDefault]] of Object.entries(MODULES)) {
|
|
46
|
+
const setting = options[name] === undefined ? onByDefault : options[name];
|
|
47
|
+
if (setting === false) continue;
|
|
48
|
+
const opts = { trustProxy: options.trustProxy, ...(setting && typeof setting === "object" ? setting : {}) };
|
|
49
|
+
// Form checks find the visitor's address the way the firewall does.
|
|
50
|
+
if (["forms", "audit", "maintenance", "crawlers", "proxy"].includes(name) && modules.firewall) opts.clientIp = modules.firewall.clientIp;
|
|
51
|
+
try {
|
|
52
|
+
modules[name] = load(core, opts);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn(`[analytics-platform] the ${name} module could not start and is off:`, error?.message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const switches = core.start(Object.keys(modules));
|
|
59
|
+
const off = (name) => () => core.log(name, "module is off");
|
|
60
|
+
const { firewall, errors, analytics, performance, crawlers, forms, headers, audit, flags, maintenance, proxy } = modules;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
// The modules that are on, by name.
|
|
64
|
+
modules: Object.keys(modules),
|
|
65
|
+
firewall: firewall || null,
|
|
66
|
+
// record({ bot, path, status, bytes, ms, ip }) and detect(userAgent), for
|
|
67
|
+
// frameworks that aren't Express.
|
|
68
|
+
crawlers: crawlers || null,
|
|
69
|
+
// The first-party proxy: scriptUrl(major) and collectUrl() are the two
|
|
70
|
+
// addresses the page's script tag needs, on this site's own domain.
|
|
71
|
+
proxy: proxy || null,
|
|
72
|
+
|
|
73
|
+
// Express / Connect / node:http middleware, before your routes: the
|
|
74
|
+
// tracker's own addresses are answered first (a visitor's beacon is our
|
|
75
|
+
// traffic, not a page of the site: it shouldn't be counted, timed or
|
|
76
|
+
// rate-limited), then crawlers are counted (a crawler the firewall turns
|
|
77
|
+
// away is still a crawler), then the firewall, then timing (blocked
|
|
78
|
+
// requests aren't timed).
|
|
79
|
+
middleware() {
|
|
80
|
+
const timed = (req, res, next) => (performance ? performance.middleware(req, res, next) : next());
|
|
81
|
+
const open = (req, res, next) => (maintenance ? maintenance.middleware(req, res, () => timed(req, res, next)) : timed(req, res, next));
|
|
82
|
+
const guarded = (req, res, next) => (firewall ? firewall.middleware(req, res, () => open(req, res, next)) : open(req, res, next));
|
|
83
|
+
const counted = (req, res, next) => (crawlers ? crawlers.middleware(req, res, () => guarded(req, res, next)) : guarded(req, res, next));
|
|
84
|
+
const withHeaders = (req, res, next) => (headers ? headers.middleware(req, res, () => counted(req, res, next)) : counted(req, res, next));
|
|
85
|
+
return function platformMiddleware(req, res, next = () => {}) {
|
|
86
|
+
if (!proxy) return withHeaders(req, res, next);
|
|
87
|
+
return proxy.middleware(req, res, () => withHeaders(req, res, next));
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// Express error middleware, after your routes. Captures 5xx errors.
|
|
92
|
+
errorHandler(handlerOptions) {
|
|
93
|
+
return errors ? errors.errorHandler(handlerOptions) : (error, req, res, next) => next(error);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
captureException: errors ? errors.captureException : off("errors"),
|
|
97
|
+
// A failed login (bot protection): pass the request, or the address.
|
|
98
|
+
loginFailed: firewall ? firewall.loginFailed : off("firewall"),
|
|
99
|
+
// Form protection: check a form before accepting it, or protect a route.
|
|
100
|
+
checkForm: forms ? forms.checkForm : async () => ({ ok: true, spam: false, reasons: [], checked: false }),
|
|
101
|
+
protectForm: forms ? forms.protectForm : () => (req, res, next) => next(),
|
|
102
|
+
// Feature flags: isEnabled("new-checkout", { user }) (false until known).
|
|
103
|
+
isEnabled: flags ? flags.isEnabled : (key, { default: fallback = false } = {}) => fallback,
|
|
104
|
+
// Audit log: audit("user.role_changed", { actor, target, details, req }).
|
|
105
|
+
audit: audit ? audit.audit : off("audit"),
|
|
106
|
+
track: analytics ? analytics.track : () => (off("analytics")(), ""),
|
|
107
|
+
identify: analytics ? analytics.identify : async () => (off("analytics")(), false),
|
|
108
|
+
lead: analytics ? analytics.lead : async () => (off("analytics")(), null),
|
|
109
|
+
|
|
110
|
+
// Resolves once each module that fetches something has tried once.
|
|
111
|
+
ready: Promise.all([switches, ...Object.values(modules).map((m) => m.ready).filter(Boolean)]).then(() => undefined),
|
|
112
|
+
|
|
113
|
+
// Sends everything waiting.
|
|
114
|
+
flush: () => Promise.all(Object.values(modules).map((m) => m.flush?.())).then(() => undefined),
|
|
115
|
+
|
|
116
|
+
// Stops background work and sends what's waiting (for a clean shutdown).
|
|
117
|
+
close: () => {
|
|
118
|
+
core.stop();
|
|
119
|
+
return Promise.all(Object.values(modules).map((m) => m.close?.())).then(() => undefined);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createPlatform };
|