analytics-platform 0.0.0-stage → 0.2.2

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 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
- # Temporary Holding Version
1
+ # analytics-platform
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 → 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,128 @@
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
+ const { clientIp } = require("./lib/firewall.js");
18
+
19
+ // Module name -> [loader, on by default].
20
+ const MODULES = {
21
+ firewall: [(core, opts) => require("./lib/firewall.js").createFirewall(core, opts), true],
22
+ errors: [(core, opts) => require("./lib/errors.js").createErrors(core, opts), true],
23
+ analytics: [(core) => require("./lib/analytics.js").createAnalytics(core), true],
24
+ performance: [(core, opts) => require("./lib/performance.js").createPerformance(core, opts), true],
25
+ crawlers: [(core, opts) => require("./lib/crawlers.js").createCrawlers(core, opts), true],
26
+ forms: [(core, opts) => require("./lib/forms.js").createForms(core, opts), true],
27
+ headers: [(core) => require("./lib/headers.js").createHeaders(core), true],
28
+ audit: [(core, opts) => require("./lib/audit.js").createAudit(core, opts), true],
29
+ flags: [(core) => require("./lib/flags.js").createFlags(core), true],
30
+ maintenance: [(core, opts) => require("./lib/maintenance.js").createMaintenance(core, opts), true],
31
+ inventory: [(core, opts) => require("./lib/inventory.js").createInventory(core, opts), true],
32
+ posture: [(core, opts) => require("./lib/posture.js").createPosture(core, opts), true],
33
+ resources: [(core, opts) => require("./lib/resources.js").createResources(core, opts), true],
34
+ proxy: [(core, opts) => require("./lib/proxy.js").createProxy(core, opts), true],
35
+ };
36
+
37
+ // Options: siteId, secret (site settings; keep it on the server), endpoint
38
+ // (the collector's URL); trustProxy (how to find the visitor's address, for
39
+ // every module that needs it: see README); debug (log send problems); and
40
+ // per module, true / false / { options }.
41
+ function createPlatform(options = {}) {
42
+ const core = createCore(options);
43
+ if (!core.valid) console.warn("[analytics-platform] siteId, secret and endpoint are required; nothing is sent and every request passes");
44
+
45
+ const modules = {};
46
+ for (const [name, [load, onByDefault]] of Object.entries(MODULES)) {
47
+ const setting = options[name] === undefined ? onByDefault : options[name];
48
+ if (setting === false) continue;
49
+ const opts = { trustProxy: options.trustProxy, ...(setting && typeof setting === "object" ? setting : {}) };
50
+ // These find the visitor's address the way the firewall does, with the
51
+ // same trustProxy, whether or not the firewall itself is on.
52
+ if (["forms", "audit", "maintenance", "crawlers", "proxy"].includes(name)) {
53
+ opts.clientIp = modules.firewall?.clientIp || ((req) => clientIp(req, options.trustProxy));
54
+ }
55
+ try {
56
+ modules[name] = load(core, opts);
57
+ } catch (error) {
58
+ console.warn(`[analytics-platform] the ${name} module could not start and is off:`, error?.message);
59
+ }
60
+ }
61
+
62
+ const switches = core.start(Object.keys(modules));
63
+ const off = (name) => () => core.log(name, "module is off");
64
+ const { firewall, errors, analytics, performance, crawlers, forms, headers, audit, flags, maintenance, proxy } = modules;
65
+
66
+ return {
67
+ // The modules that are on, by name.
68
+ modules: Object.keys(modules),
69
+ firewall: firewall || null,
70
+ // record({ bot, path, status, bytes, ms, ip }) and detect(userAgent), for
71
+ // frameworks that aren't Express.
72
+ crawlers: crawlers || null,
73
+ // The first-party proxy: scriptUrl(major) and collectUrl() are the two
74
+ // addresses the page's script tag needs, on this site's own domain.
75
+ proxy: proxy || null,
76
+
77
+ // Express / Connect / node:http middleware, before your routes: the
78
+ // tracker's own addresses are answered first (a visitor's beacon is our
79
+ // traffic, not a page of the site: it shouldn't be counted, timed or
80
+ // rate-limited), then crawlers are counted (a crawler the firewall turns
81
+ // away is still a crawler), then the firewall, then timing (blocked
82
+ // requests aren't timed).
83
+ middleware() {
84
+ const timed = (req, res, next) => (performance ? performance.middleware(req, res, next) : next());
85
+ const open = (req, res, next) => (maintenance ? maintenance.middleware(req, res, () => timed(req, res, next)) : timed(req, res, next));
86
+ const guarded = (req, res, next) => (firewall ? firewall.middleware(req, res, () => open(req, res, next)) : open(req, res, next));
87
+ const counted = (req, res, next) => (crawlers ? crawlers.middleware(req, res, () => guarded(req, res, next)) : guarded(req, res, next));
88
+ const withHeaders = (req, res, next) => (headers ? headers.middleware(req, res, () => counted(req, res, next)) : counted(req, res, next));
89
+ return function platformMiddleware(req, res, next = () => {}) {
90
+ if (!proxy) return withHeaders(req, res, next);
91
+ return proxy.middleware(req, res, () => withHeaders(req, res, next));
92
+ };
93
+ },
94
+
95
+ // Express error middleware, after your routes. Captures 5xx errors.
96
+ errorHandler(handlerOptions) {
97
+ return errors ? errors.errorHandler(handlerOptions) : (error, req, res, next) => next(error);
98
+ },
99
+
100
+ captureException: errors ? errors.captureException : off("errors"),
101
+ // A failed login (bot protection): pass the request, or the address.
102
+ loginFailed: firewall ? firewall.loginFailed : off("firewall"),
103
+ // Form protection: check a form before accepting it, or protect a route.
104
+ checkForm: forms ? forms.checkForm : async () => ({ ok: true, spam: false, reasons: [], checked: false }),
105
+ protectForm: forms ? forms.protectForm : () => (req, res, next) => next(),
106
+ // Feature flags: isEnabled("new-checkout", { user }) (false until known).
107
+ isEnabled: flags ? flags.isEnabled : (key, { default: fallback = false } = {}) => fallback,
108
+ // Audit log: audit("user.role_changed", { actor, target, details, req }).
109
+ audit: audit ? audit.audit : off("audit"),
110
+ track: analytics ? analytics.track : () => (off("analytics")(), ""),
111
+ identify: analytics ? analytics.identify : async () => (off("analytics")(), false),
112
+ lead: analytics ? analytics.lead : async () => (off("analytics")(), null),
113
+
114
+ // Resolves once each module that fetches something has tried once.
115
+ ready: Promise.all([switches, ...Object.values(modules).map((m) => m.ready).filter(Boolean)]).then(() => undefined),
116
+
117
+ // Sends everything waiting.
118
+ flush: () => Promise.all(Object.values(modules).map((m) => m.flush?.())).then(() => undefined),
119
+
120
+ // Stops background work and sends what's waiting (for a clean shutdown).
121
+ close: () => {
122
+ core.stop();
123
+ return Promise.all(Object.values(modules).map((m) => m.close?.())).then(() => undefined);
124
+ },
125
+ };
126
+ }
127
+
128
+ module.exports = { createPlatform };