enpilink 1.0.2 → 1.0.4
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/dist/server/analytics.d.ts +61 -21
- package/dist/server/analytics.js +107 -52
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/analytics.test.js +63 -12
- package/dist/server/analytics.test.js.map +1 -1
- package/dist/server/capture-gate.d.ts +47 -0
- package/dist/server/capture-gate.js +39 -0
- package/dist/server/capture-gate.js.map +1 -0
- package/dist/server/capture-gate.test.d.ts +1 -0
- package/dist/server/capture-gate.test.js +124 -0
- package/dist/server/capture-gate.test.js.map +1 -0
- package/dist/server/config/config.test.js +201 -8
- package/dist/server/config/config.test.js.map +1 -1
- package/dist/server/config/index.d.ts +3 -2
- package/dist/server/config/index.js +3 -2
- package/dist/server/config/index.js.map +1 -1
- package/dist/server/config/presets.d.ts +36 -0
- package/dist/server/config/presets.js +46 -0
- package/dist/server/config/presets.js.map +1 -0
- package/dist/server/config/resolve.d.ts +42 -3
- package/dist/server/config/resolve.js +88 -8
- package/dist/server/config/resolve.js.map +1 -1
- package/dist/server/config/router.d.ts +22 -14
- package/dist/server/config/router.js +163 -51
- package/dist/server/config/router.js.map +1 -1
- package/dist/server/config/schema.d.ts +39 -1
- package/dist/server/config/schema.js +121 -0
- package/dist/server/config/schema.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/storage/memory.d.ts +1 -0
- package/dist/server/storage/memory.js +14 -0
- package/dist/server/storage/memory.js.map +1 -1
- package/dist/server/storage/memory.test.js +17 -0
- package/dist/server/storage/memory.test.js.map +1 -1
- package/dist/server/storage/postgres.d.ts +1 -0
- package/dist/server/storage/postgres.js +12 -0
- package/dist/server/storage/postgres.js.map +1 -1
- package/dist/server/storage/postgres.test.js +17 -0
- package/dist/server/storage/postgres.test.js.map +1 -1
- package/dist/server/storage/sqlite.d.ts +1 -0
- package/dist/server/storage/sqlite.js +21 -0
- package/dist/server/storage/sqlite.js.map +1 -1
- package/dist/server/storage/sqlite.test.js +17 -0
- package/dist/server/storage/sqlite.test.js.map +1 -1
- package/dist/server/storage/types.d.ts +6 -0
- package/dist/server/storage/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,18 +1,30 @@
|
|
|
1
|
+
import { type CaptureGate } from "./capture-gate.js";
|
|
1
2
|
import type { McpMiddlewareEntry, McpMiddlewareFn } from "./middleware.js";
|
|
2
3
|
import { type OtelSink } from "./otel.js";
|
|
3
4
|
import type { StorageAdapter } from "./storage/types.js";
|
|
4
5
|
/**
|
|
5
|
-
* Analytics + log capture (M2)
|
|
6
|
+
* Analytics + log capture (M2) — now with a LIVE runtime toggle (bugfix).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
* `server.storage` getter or {@link getActiveStorage}.
|
|
8
|
+
* A single {@link StorageAdapter} is shared in-process: the analytics
|
|
9
|
+
* middleware writes `tool_call` events to it, the log sink mirrors server logs
|
|
10
|
+
* to it, and the observability/config APIs read from the very same instance via
|
|
11
|
+
* the `server.storage` getter or {@link getActiveStorage}.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Storage activation (so the Configuration + observability UI always has a
|
|
14
|
+
* backing store) vs. capture gating (whether tool calls are recorded) are now
|
|
15
|
+
* DECOUPLED:
|
|
16
|
+
*
|
|
17
|
+
* - **Storage** is activated whenever the admin/config UI is reachable: always
|
|
18
|
+
* in dev (default `memory`, honoring `ENPILINK_STORAGE`/`ENPILINK_DB_PATH`),
|
|
19
|
+
* and in prod-admin (handled separately in `admin.ts`). It reuses any already
|
|
20
|
+
* active adapter — never double-inits.
|
|
21
|
+
* - **Capture** is governed at runtime by the resolved `analytics.enabled`
|
|
22
|
+
* config value (env > file > db > default) via the {@link CaptureGate}, so
|
|
23
|
+
* toggling it in the UI takes effect WITHOUT a restart. `ENPILINK_ANALYTICS`
|
|
24
|
+
* still works as an env override (env > db).
|
|
25
|
+
*
|
|
26
|
+
* The default `memory` adapter creates NO `enpilink.db` file and does zero
|
|
27
|
+
* network; OTel stays independently gated and off by default.
|
|
16
28
|
*/
|
|
17
29
|
/** Options for {@link installAnalytics}. */
|
|
18
30
|
export interface InstallAnalyticsOptions {
|
|
@@ -33,25 +45,53 @@ export declare function analyticsEnabled(): boolean;
|
|
|
33
45
|
* NEVER touches disk and is never on by default.
|
|
34
46
|
*/
|
|
35
47
|
export declare function mockEnabled(): boolean;
|
|
48
|
+
/** Options controlling the live capture gate (injectable for tests). */
|
|
49
|
+
export interface AnalyticsMiddlewareOptions {
|
|
50
|
+
/** Read the live capture gate. Defaults to the shared {@link getCaptureGate}. */
|
|
51
|
+
gate?: () => CaptureGate;
|
|
52
|
+
/** RNG for sampling `[0, 1)`. Defaults to `Math.random`. */
|
|
53
|
+
rng?: () => number;
|
|
54
|
+
}
|
|
36
55
|
/**
|
|
37
56
|
* Build the analytics middleware entry. Times each request around `next()`,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* slow a tool call. Recording is
|
|
57
|
+
* and — ONLY when the live capture gate says so — records a `tool_call`-typed
|
|
58
|
+
* event (capturing the tool name for `tools/call`). Always swallows storage
|
|
59
|
+
* errors so a storage failure can never break or slow a tool call. Recording is
|
|
60
|
+
* fire-and-forget (non-blocking).
|
|
61
|
+
*
|
|
62
|
+
* The gate is a cheap, synchronous in-memory snapshot of the resolved
|
|
63
|
+
* `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),
|
|
64
|
+
* so toggling analytics in the UI takes effect live without a restart and the
|
|
65
|
+
* hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs
|
|
66
|
+
* identically whether capture is on or off; only the record/export at the end is
|
|
67
|
+
* gated.
|
|
41
68
|
*/
|
|
42
|
-
export declare function createAnalyticsMiddleware(storage: StorageAdapter, now?: () => number, otel?: OtelSink | null): McpMiddlewareFn;
|
|
69
|
+
export declare function createAnalyticsMiddleware(storage: StorageAdapter, now?: () => number, otel?: OtelSink | null, opts?: AnalyticsMiddlewareOptions): McpMiddlewareFn;
|
|
43
70
|
/**
|
|
44
|
-
* Install analytics on a server
|
|
71
|
+
* Install analytics on a server.
|
|
45
72
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
73
|
+
* STORAGE activation (decoupled from capture):
|
|
74
|
+
* - Reuse any already-active adapter ({@link getActiveStorage}) — never
|
|
75
|
+
* double-init.
|
|
76
|
+
* - In `--mock` mode, force a fresh in-memory adapter and seed it.
|
|
77
|
+
* - Otherwise, in DEV activate the configured adapter (default `memory`,
|
|
78
|
+
* honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +
|
|
79
|
+
* observability UI always has a backing store — removing the "no active
|
|
80
|
+
* storage" 409 on the first config write. `memory` creates NO file and does
|
|
81
|
+
* zero network.
|
|
82
|
+
* - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't
|
|
83
|
+
* reachable; prod-admin activates its own store in `admin.ts`). This preserves
|
|
84
|
+
* the "no db / no network when off in prod" guarantee.
|
|
50
85
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
86
|
+
* CAPTURE gating: the returned middleware records events only when the live
|
|
87
|
+
* {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env
|
|
88
|
+
* > file > db > default) allows it — so the toggle is live (no restart). The
|
|
89
|
+
* gate is resolved once here and refreshed on config writes by the router. In
|
|
90
|
+
* `--mock` mode capture is force-enabled for the session.
|
|
53
91
|
*
|
|
54
|
-
* @returns the active storage + middleware entry, or `null` when
|
|
92
|
+
* @returns the active storage + middleware entry + otel sink, or `null` when no
|
|
93
|
+
* storage was activated (prod without mock/admin) — zero overhead, no file, no
|
|
94
|
+
* network, no middleware.
|
|
55
95
|
*/
|
|
56
96
|
export declare function installAnalytics(opts?: InstallAnalyticsOptions): Promise<{
|
|
57
97
|
storage: StorageAdapter;
|
package/dist/server/analytics.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getCaptureGate, refreshCaptureGate, setCaptureGate, } from "./capture-gate.js";
|
|
2
|
+
import { getActiveStorage, setActiveStorage } from "./log-sink.js";
|
|
2
3
|
import { seedMockData } from "./mock-seed.js";
|
|
3
4
|
import { initOtel } from "./otel.js";
|
|
4
5
|
import { resolveStorageAdapter } from "./storage/index.js";
|
|
@@ -11,6 +12,10 @@ import { MemoryStorageAdapter } from "./storage/memory.js";
|
|
|
11
12
|
function resolveSessionStorage(mock) {
|
|
12
13
|
return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();
|
|
13
14
|
}
|
|
15
|
+
/** Whether the process is running in production (no dev admin plane). */
|
|
16
|
+
function isProduction() {
|
|
17
|
+
return process.env.NODE_ENV === "production";
|
|
18
|
+
}
|
|
14
19
|
/** Truthy values that enable analytics via {@link analyticsEnabled}. */
|
|
15
20
|
const TRUTHY = new Set(["1", "true", "yes", "on"]);
|
|
16
21
|
/**
|
|
@@ -50,11 +55,21 @@ function toolNameOf(method, params) {
|
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
57
|
* Build the analytics middleware entry. Times each request around `next()`,
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* slow a tool call. Recording is
|
|
58
|
+
* and — ONLY when the live capture gate says so — records a `tool_call`-typed
|
|
59
|
+
* event (capturing the tool name for `tools/call`). Always swallows storage
|
|
60
|
+
* errors so a storage failure can never break or slow a tool call. Recording is
|
|
61
|
+
* fire-and-forget (non-blocking).
|
|
62
|
+
*
|
|
63
|
+
* The gate is a cheap, synchronous in-memory snapshot of the resolved
|
|
64
|
+
* `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),
|
|
65
|
+
* so toggling analytics in the UI takes effect live without a restart and the
|
|
66
|
+
* hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs
|
|
67
|
+
* identically whether capture is on or off; only the record/export at the end is
|
|
68
|
+
* gated.
|
|
56
69
|
*/
|
|
57
|
-
export function createAnalyticsMiddleware(storage, now = Date.now, otel = null) {
|
|
70
|
+
export function createAnalyticsMiddleware(storage, now = Date.now, otel = null, opts = {}) {
|
|
71
|
+
const readGate = opts.gate ?? getCaptureGate;
|
|
72
|
+
const rng = opts.rng ?? Math.random;
|
|
58
73
|
return async (request, _extra, next) => {
|
|
59
74
|
const start = now();
|
|
60
75
|
const tool = toolNameOf(request.method, request.params);
|
|
@@ -77,25 +92,31 @@ export function createAnalyticsMiddleware(storage, now = Date.now, otel = null)
|
|
|
77
92
|
throw err;
|
|
78
93
|
}
|
|
79
94
|
finally {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
// Live gate: skip recording entirely when analytics is off, or when this
|
|
96
|
+
// call falls outside the sample rate. Cheap + synchronous + never throws.
|
|
97
|
+
const { enabled, sampleRate } = readGate();
|
|
98
|
+
const sampled = sampleRate >= 1 || (sampleRate > 0 && rng() < sampleRate);
|
|
99
|
+
if (enabled && sampled) {
|
|
100
|
+
const ms = now() - start;
|
|
101
|
+
const event = {
|
|
102
|
+
ts: start,
|
|
103
|
+
type: "tool_call",
|
|
104
|
+
tool,
|
|
105
|
+
method: request.method,
|
|
106
|
+
ms,
|
|
107
|
+
ok,
|
|
108
|
+
error,
|
|
109
|
+
};
|
|
110
|
+
// Fire-and-forget; never block or throw into the request path.
|
|
111
|
+
void recordSafely(storage, event);
|
|
112
|
+
// Optional OTel export — guarded, synchronous, error-swallowing.
|
|
113
|
+
if (otel) {
|
|
114
|
+
try {
|
|
115
|
+
otel.record(event);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// OTel export must never break or slow a tool call.
|
|
119
|
+
}
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
122
|
}
|
|
@@ -111,40 +132,65 @@ async function recordSafely(storage, event) {
|
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
/**
|
|
114
|
-
* Install analytics on a server
|
|
135
|
+
* Install analytics on a server.
|
|
115
136
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
137
|
+
* STORAGE activation (decoupled from capture):
|
|
138
|
+
* - Reuse any already-active adapter ({@link getActiveStorage}) — never
|
|
139
|
+
* double-init.
|
|
140
|
+
* - In `--mock` mode, force a fresh in-memory adapter and seed it.
|
|
141
|
+
* - Otherwise, in DEV activate the configured adapter (default `memory`,
|
|
142
|
+
* honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +
|
|
143
|
+
* observability UI always has a backing store — removing the "no active
|
|
144
|
+
* storage" 409 on the first config write. `memory` creates NO file and does
|
|
145
|
+
* zero network.
|
|
146
|
+
* - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't
|
|
147
|
+
* reachable; prod-admin activates its own store in `admin.ts`). This preserves
|
|
148
|
+
* the "no db / no network when off in prod" guarantee.
|
|
120
149
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
150
|
+
* CAPTURE gating: the returned middleware records events only when the live
|
|
151
|
+
* {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env
|
|
152
|
+
* > file > db > default) allows it — so the toggle is live (no restart). The
|
|
153
|
+
* gate is resolved once here and refreshed on config writes by the router. In
|
|
154
|
+
* `--mock` mode capture is force-enabled for the session.
|
|
123
155
|
*
|
|
124
|
-
* @returns the active storage + middleware entry, or `null` when
|
|
156
|
+
* @returns the active storage + middleware entry + otel sink, or `null` when no
|
|
157
|
+
* storage was activated (prod without mock/admin) — zero overhead, no file, no
|
|
158
|
+
* network, no middleware.
|
|
125
159
|
*/
|
|
126
160
|
export async function installAnalytics(opts = {}) {
|
|
127
|
-
// `--mock` (ENPILINK_MOCK) force-enables
|
|
128
|
-
//
|
|
161
|
+
// `--mock` (ENPILINK_MOCK) force-enables capture for the session and uses a
|
|
162
|
+
// throwaway in-memory store so demos work with no real traffic and no disk.
|
|
129
163
|
const mock = mockEnabled();
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let
|
|
134
|
-
|
|
135
|
-
storage
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
164
|
+
// Reuse an already-active adapter if one exists (e.g. set elsewhere); never
|
|
165
|
+
// double-init. Otherwise decide whether to activate one.
|
|
166
|
+
let storage = getActiveStorage();
|
|
167
|
+
let ownedHere = false;
|
|
168
|
+
if (!storage) {
|
|
169
|
+
// In prod we don't activate storage here UNLESS analytics is explicitly
|
|
170
|
+
// enabled via env (the historical M2 behavior — capture to a resolved
|
|
171
|
+
// store). Otherwise the config UI isn't reachable (prod-admin handles its
|
|
172
|
+
// own store), so activating nothing preserves the "no db / no network when
|
|
173
|
+
// off" guarantee. In dev (or mock) we always activate so the UI works.
|
|
174
|
+
if (!mock && isProduction() && !analyticsEnabled()) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
storage = resolveSessionStorage(mock);
|
|
179
|
+
await storage.init();
|
|
180
|
+
ownedHere = true;
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// Never let a storage failure break server startup.
|
|
184
|
+
console.error("[enpilink] analytics storage init failed:", err instanceof Error ? err.message : err);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
setActiveStorage(storage);
|
|
142
188
|
}
|
|
143
|
-
|
|
144
|
-
// In `--mock` mode, seed the in-memory storage with a deterministic demo
|
|
189
|
+
// In `--mock` mode, seed the (in-memory) storage with a deterministic demo
|
|
145
190
|
// dataset so the Dashboard renders full immediately (no real traffic).
|
|
146
|
-
// Determinism: a fixed seed + a base timestamp captured once here.
|
|
147
|
-
|
|
191
|
+
// Determinism: a fixed seed + a base timestamp captured once here. Only seed a
|
|
192
|
+
// store we just created here, never an adopted/pre-existing one.
|
|
193
|
+
if (mock && ownedHere) {
|
|
148
194
|
const now = (opts.now ?? Date.now)();
|
|
149
195
|
try {
|
|
150
196
|
await seedMockData(storage, { now });
|
|
@@ -153,9 +199,18 @@ export async function installAnalytics(opts = {}) {
|
|
|
153
199
|
// A seeding failure must never break server startup.
|
|
154
200
|
}
|
|
155
201
|
}
|
|
202
|
+
// Initialize the live capture gate. `--mock` force-enables capture for the
|
|
203
|
+
// session (full sample); otherwise resolve from env/file/db/default so the UI
|
|
204
|
+
// toggle (and any env override) governs capture live.
|
|
205
|
+
if (mock) {
|
|
206
|
+
setCaptureGate({ enabled: true, sampleRate: 1 });
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
await refreshCaptureGate();
|
|
210
|
+
}
|
|
156
211
|
// Optional OTel export (M6): off by default, zero network/imports when unset.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
212
|
+
// Mock mode never exports (it's dev-only demo data); requires the explicit env
|
|
213
|
+
// opt-in.
|
|
159
214
|
const otel = mock ? null : await initOtel();
|
|
160
215
|
const entry = {
|
|
161
216
|
// "request" matches every (non-notification) request so non-tool methods
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"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","sourcesContent":["import { setActiveStorage } from \"./log-sink.js\";\nimport type { McpMiddlewareEntry, McpMiddlewareFn } from \"./middleware.js\";\nimport { seedMockData } from \"./mock-seed.js\";\nimport { initOtel, type OtelSink } from \"./otel.js\";\nimport { resolveStorageAdapter } from \"./storage/index.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { StorageAdapter } from \"./storage/types.js\";\n\n/**\n * Analytics + log capture (M2). Opt-in, env-gated, zero overhead when off.\n *\n * When enabled, a single {@link StorageAdapter} is resolved + `init()`ed at\n * server startup and shared in-process: the analytics middleware writes\n * `tool_call` events to it, the log sink mirrors server logs to it, and (M3)\n * the observability API reads from the very same instance via the\n * `server.storage` getter or {@link getActiveStorage}.\n *\n * Gating: OFF unless `ENPILINK_ANALYTICS` is `1` or `true` (case-insensitive).\n * When OFF, no adapter is resolved or initialized (so no `enpilink.db` is\n * created), no middleware is registered, and there is zero network activity.\n */\n\n/** Options for {@link installAnalytics}. */\nexport interface InstallAnalyticsOptions {\n /**\n * Inject a clock for deterministic tests. Defaults to `Date.now`.\n */\n now?: () => number;\n}\n\n/**\n * Resolve a storage adapter for the session. In `--mock` mode we force an\n * in-memory adapter (the demo seed must never touch disk and must vanish on\n * exit); otherwise the configured `ENPILINK_STORAGE` adapter is used.\n */\nfunction resolveSessionStorage(mock: boolean): StorageAdapter {\n return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();\n}\n\n/** Truthy values that enable analytics via {@link analyticsEnabled}. */\nconst TRUTHY = new Set([\"1\", \"true\", \"yes\", \"on\"]);\n\n/**\n * Whether analytics is enabled. OFF by default; enable with\n * `ENPILINK_ANALYTICS=1` (also accepts `true`/`yes`/`on`, case-insensitive).\n */\nexport function analyticsEnabled(): boolean {\n const raw = process.env.ENPILINK_ANALYTICS;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Whether the `--mock` demo seed is enabled (`ENPILINK_MOCK`=1/true/yes/on).\n * Mock mode is opt-in only and IMPLIES analytics-on + in-memory storage for the\n * session, so the Dashboard renders full demo data with NO real traffic. It\n * NEVER touches disk and is never on by default.\n */\nexport function mockEnabled(): boolean {\n // The demo seed is DEV-ONLY: it must never seed a real deployment. In\n // production `ENPILINK_MOCK` is ignored entirely (read the literal so the\n // guard survives DCE and is unambiguous).\n if (process.env.NODE_ENV === \"production\") {\n return false;\n }\n const raw = process.env.ENPILINK_MOCK;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Extract the tool name from a `tools/call` request's params. Returns\n * `undefined` for non-`tools/call` methods or malformed params, never throws.\n */\nfunction toolNameOf(\n method: string,\n params: Record<string, unknown>,\n): string | undefined {\n if (method !== \"tools/call\") {\n return undefined;\n }\n const name = params?.name;\n return typeof name === \"string\" ? name : undefined;\n}\n\n/**\n * Build the analytics middleware entry. Times each request around `next()`,\n * records a `tool_call`-typed event (capturing the tool name for `tools/call`),\n * and ALWAYS swallows storage errors so a storage failure can never break or\n * slow a tool call. Recording is fire-and-forget (non-blocking).\n */\nexport function createAnalyticsMiddleware(\n storage: StorageAdapter,\n now: () => number = Date.now,\n otel: OtelSink | null = null,\n): McpMiddlewareFn {\n return async (request, _extra, next) => {\n const start = now();\n const tool = toolNameOf(request.method, request.params);\n let ok = true;\n let error: string | undefined;\n\n try {\n const result = await next();\n // A tool result with `isError: true` is a soft (handled) failure.\n if (\n result &&\n typeof result === \"object\" &&\n (result as { isError?: unknown }).isError === true\n ) {\n ok = false;\n }\n return result;\n } catch (err) {\n // Record the failure, then rethrow so behavior is unchanged.\n ok = false;\n error = err instanceof Error ? err.message : String(err);\n throw err;\n } finally {\n const ms = now() - start;\n const event = {\n ts: start,\n type: \"tool_call\",\n tool,\n method: request.method,\n ms,\n ok,\n error,\n } as const;\n // Fire-and-forget; never block or throw into the request path.\n void recordSafely(storage, event);\n // Optional OTel export — guarded, synchronous, error-swallowing.\n if (otel) {\n try {\n otel.record(event);\n } catch {\n // OTel export must never break or slow a tool call.\n }\n }\n }\n };\n}\n\n/** Record an event, swallowing any storage error. */\nasync function recordSafely(\n storage: StorageAdapter,\n event: Parameters<StorageAdapter[\"recordEvent\"]>[0],\n): Promise<void> {\n try {\n await storage.recordEvent(event);\n } catch {\n // A storage failure must never break or slow a tool call.\n }\n}\n\n/**\n * Install analytics on a server, ONLY when enabled (`ENPILINK_ANALYTICS`).\n *\n * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`\n * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the\n * active storage for the log sink + `getActiveStorage()`, and returns the\n * built analytics middleware entry to splice into the chain.\n *\n * When disabled: resolves/initializes NOTHING and returns `null` — zero\n * overhead, zero network, no `enpilink.db` created.\n *\n * @returns the active storage + middleware entry, or `null` when disabled.\n */\nexport async function installAnalytics(\n opts: InstallAnalyticsOptions = {},\n): Promise<{\n storage: StorageAdapter;\n entry: McpMiddlewareEntry;\n otel: OtelSink | null;\n} | null> {\n // `--mock` (ENPILINK_MOCK) force-enables analytics for the session even when\n // the env-based gating is off, so demos work without setting both flags.\n const mock = mockEnabled();\n if (!mock && !analyticsEnabled()) {\n return null;\n }\n\n let storage: StorageAdapter;\n try {\n storage = resolveSessionStorage(mock);\n await storage.init();\n } catch (err) {\n // Never let an analytics/storage failure break server startup.\n console.error(\n \"[enpilink] analytics disabled: storage init failed:\",\n err instanceof Error ? err.message : err,\n );\n return null;\n }\n\n setActiveStorage(storage);\n\n // In `--mock` mode, seed the in-memory storage with a deterministic demo\n // dataset so the Dashboard renders full immediately (no real traffic).\n // Determinism: a fixed seed + a base timestamp captured once here.\n if (mock) {\n const now = (opts.now ?? Date.now)();\n try {\n await seedMockData(storage, { now });\n } catch {\n // A seeding failure must never break server startup.\n }\n }\n\n // Optional OTel export (M6): off by default, zero network/imports when unset.\n // Initialized once here and fed by the middleware alongside storage. Mock mode\n // never exports (it's dev-only demo data); requires the explicit env opt-in.\n const otel = mock ? null : await initOtel();\n\n const entry: McpMiddlewareEntry = {\n // \"request\" matches every (non-notification) request so non-tool methods\n // are counted too; the handler captures the tool name for tools/call.\n filter: \"request\",\n handler: createAnalyticsMiddleware(storage, opts.now, otel),\n };\n\n return { storage, entry, otel };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n type CaptureGate,\n getCaptureGate,\n refreshCaptureGate,\n setCaptureGate,\n} from \"./capture-gate.js\";\nimport { getActiveStorage, setActiveStorage } from \"./log-sink.js\";\nimport type { McpMiddlewareEntry, McpMiddlewareFn } from \"./middleware.js\";\nimport { seedMockData } from \"./mock-seed.js\";\nimport { initOtel, type OtelSink } from \"./otel.js\";\nimport { resolveStorageAdapter } from \"./storage/index.js\";\nimport { MemoryStorageAdapter } from \"./storage/memory.js\";\nimport type { StorageAdapter } from \"./storage/types.js\";\n\n/**\n * Analytics + log capture (M2) — now with a LIVE runtime toggle (bugfix).\n *\n * A single {@link StorageAdapter} is shared in-process: the analytics\n * middleware writes `tool_call` events to it, the log sink mirrors server logs\n * to it, and the observability/config APIs read from the very same instance via\n * the `server.storage` getter or {@link getActiveStorage}.\n *\n * Storage activation (so the Configuration + observability UI always has a\n * backing store) vs. capture gating (whether tool calls are recorded) are now\n * DECOUPLED:\n *\n * - **Storage** is activated whenever the admin/config UI is reachable: always\n * in dev (default `memory`, honoring `ENPILINK_STORAGE`/`ENPILINK_DB_PATH`),\n * and in prod-admin (handled separately in `admin.ts`). It reuses any already\n * active adapter — never double-inits.\n * - **Capture** is governed at runtime by the resolved `analytics.enabled`\n * config value (env > file > db > default) via the {@link CaptureGate}, so\n * toggling it in the UI takes effect WITHOUT a restart. `ENPILINK_ANALYTICS`\n * still works as an env override (env > db).\n *\n * The default `memory` adapter creates NO `enpilink.db` file and does zero\n * network; OTel stays independently gated and off by default.\n */\n\n/** Options for {@link installAnalytics}. */\nexport interface InstallAnalyticsOptions {\n /**\n * Inject a clock for deterministic tests. Defaults to `Date.now`.\n */\n now?: () => number;\n}\n\n/**\n * Resolve a storage adapter for the session. In `--mock` mode we force an\n * in-memory adapter (the demo seed must never touch disk and must vanish on\n * exit); otherwise the configured `ENPILINK_STORAGE` adapter is used.\n */\nfunction resolveSessionStorage(mock: boolean): StorageAdapter {\n return mock ? new MemoryStorageAdapter() : resolveStorageAdapter();\n}\n\n/** Whether the process is running in production (no dev admin plane). */\nfunction isProduction(): boolean {\n return process.env.NODE_ENV === \"production\";\n}\n\n/** Truthy values that enable analytics via {@link analyticsEnabled}. */\nconst TRUTHY = new Set([\"1\", \"true\", \"yes\", \"on\"]);\n\n/**\n * Whether analytics is enabled. OFF by default; enable with\n * `ENPILINK_ANALYTICS=1` (also accepts `true`/`yes`/`on`, case-insensitive).\n */\nexport function analyticsEnabled(): boolean {\n const raw = process.env.ENPILINK_ANALYTICS;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Whether the `--mock` demo seed is enabled (`ENPILINK_MOCK`=1/true/yes/on).\n * Mock mode is opt-in only and IMPLIES analytics-on + in-memory storage for the\n * session, so the Dashboard renders full demo data with NO real traffic. It\n * NEVER touches disk and is never on by default.\n */\nexport function mockEnabled(): boolean {\n // The demo seed is DEV-ONLY: it must never seed a real deployment. In\n // production `ENPILINK_MOCK` is ignored entirely (read the literal so the\n // guard survives DCE and is unambiguous).\n if (process.env.NODE_ENV === \"production\") {\n return false;\n }\n const raw = process.env.ENPILINK_MOCK;\n return raw !== undefined && TRUTHY.has(raw.trim().toLowerCase());\n}\n\n/**\n * Extract the tool name from a `tools/call` request's params. Returns\n * `undefined` for non-`tools/call` methods or malformed params, never throws.\n */\nfunction toolNameOf(\n method: string,\n params: Record<string, unknown>,\n): string | undefined {\n if (method !== \"tools/call\") {\n return undefined;\n }\n const name = params?.name;\n return typeof name === \"string\" ? name : undefined;\n}\n\n/** Options controlling the live capture gate (injectable for tests). */\nexport interface AnalyticsMiddlewareOptions {\n /** Read the live capture gate. Defaults to the shared {@link getCaptureGate}. */\n gate?: () => CaptureGate;\n /** RNG for sampling `[0, 1)`. Defaults to `Math.random`. */\n rng?: () => number;\n}\n\n/**\n * Build the analytics middleware entry. Times each request around `next()`,\n * and — ONLY when the live capture gate says so — records a `tool_call`-typed\n * event (capturing the tool name for `tools/call`). Always swallows storage\n * errors so a storage failure can never break or slow a tool call. Recording is\n * fire-and-forget (non-blocking).\n *\n * The gate is a cheap, synchronous in-memory snapshot of the resolved\n * `analytics.enabled` / `analytics.sampleRate` config (see `capture-gate.ts`),\n * so toggling analytics in the UI takes effect live without a restart and the\n * hot path does no DB read. `next()` is ALWAYS awaited so the tool call runs\n * identically whether capture is on or off; only the record/export at the end is\n * gated.\n */\nexport function createAnalyticsMiddleware(\n storage: StorageAdapter,\n now: () => number = Date.now,\n otel: OtelSink | null = null,\n opts: AnalyticsMiddlewareOptions = {},\n): McpMiddlewareFn {\n const readGate = opts.gate ?? getCaptureGate;\n const rng = opts.rng ?? Math.random;\n return async (request, _extra, next) => {\n const start = now();\n const tool = toolNameOf(request.method, request.params);\n let ok = true;\n let error: string | undefined;\n\n try {\n const result = await next();\n // A tool result with `isError: true` is a soft (handled) failure.\n if (\n result &&\n typeof result === \"object\" &&\n (result as { isError?: unknown }).isError === true\n ) {\n ok = false;\n }\n return result;\n } catch (err) {\n // Record the failure, then rethrow so behavior is unchanged.\n ok = false;\n error = err instanceof Error ? err.message : String(err);\n throw err;\n } finally {\n // Live gate: skip recording entirely when analytics is off, or when this\n // call falls outside the sample rate. Cheap + synchronous + never throws.\n const { enabled, sampleRate } = readGate();\n const sampled = sampleRate >= 1 || (sampleRate > 0 && rng() < sampleRate);\n if (enabled && sampled) {\n const ms = now() - start;\n const event = {\n ts: start,\n type: \"tool_call\",\n tool,\n method: request.method,\n ms,\n ok,\n error,\n } as const;\n // Fire-and-forget; never block or throw into the request path.\n void recordSafely(storage, event);\n // Optional OTel export — guarded, synchronous, error-swallowing.\n if (otel) {\n try {\n otel.record(event);\n } catch {\n // OTel export must never break or slow a tool call.\n }\n }\n }\n }\n };\n}\n\n/** Record an event, swallowing any storage error. */\nasync function recordSafely(\n storage: StorageAdapter,\n event: Parameters<StorageAdapter[\"recordEvent\"]>[0],\n): Promise<void> {\n try {\n await storage.recordEvent(event);\n } catch {\n // A storage failure must never break or slow a tool call.\n }\n}\n\n/**\n * Install analytics on a server.\n *\n * STORAGE activation (decoupled from capture):\n * - Reuse any already-active adapter ({@link getActiveStorage}) — never\n * double-init.\n * - In `--mock` mode, force a fresh in-memory adapter and seed it.\n * - Otherwise, in DEV activate the configured adapter (default `memory`,\n * honoring `ENPILINK_STORAGE` / `ENPILINK_DB_PATH`) so the Configuration +\n * observability UI always has a backing store — removing the \"no active\n * storage\" 409 on the first config write. `memory` creates NO file and does\n * zero network.\n * - In PRODUCTION WITHOUT mock, activate NOTHING here (the config UI isn't\n * reachable; prod-admin activates its own store in `admin.ts`). This preserves\n * the \"no db / no network when off in prod\" guarantee.\n *\n * CAPTURE gating: the returned middleware records events only when the live\n * {@link CaptureGate} (resolved `analytics.enabled`/`analytics.sampleRate`, env\n * > file > db > default) allows it — so the toggle is live (no restart). The\n * gate is resolved once here and refreshed on config writes by the router. In\n * `--mock` mode capture is force-enabled for the session.\n *\n * @returns the active storage + middleware entry + otel sink, or `null` when no\n * storage was activated (prod without mock/admin) — zero overhead, no file, no\n * network, no middleware.\n */\nexport async function installAnalytics(\n opts: InstallAnalyticsOptions = {},\n): Promise<{\n storage: StorageAdapter;\n entry: McpMiddlewareEntry;\n otel: OtelSink | null;\n} | null> {\n // `--mock` (ENPILINK_MOCK) force-enables capture for the session and uses a\n // throwaway in-memory store so demos work with no real traffic and no disk.\n const mock = mockEnabled();\n\n // Reuse an already-active adapter if one exists (e.g. set elsewhere); never\n // double-init. Otherwise decide whether to activate one.\n let storage = getActiveStorage();\n let ownedHere = false;\n if (!storage) {\n // In prod we don't activate storage here UNLESS analytics is explicitly\n // enabled via env (the historical M2 behavior — capture to a resolved\n // store). Otherwise the config UI isn't reachable (prod-admin handles its\n // own store), so activating nothing preserves the \"no db / no network when\n // off\" guarantee. In dev (or mock) we always activate so the UI works.\n if (!mock && isProduction() && !analyticsEnabled()) {\n return null;\n }\n try {\n storage = resolveSessionStorage(mock);\n await storage.init();\n ownedHere = true;\n } catch (err) {\n // Never let a storage failure break server startup.\n console.error(\n \"[enpilink] analytics storage init failed:\",\n err instanceof Error ? err.message : err,\n );\n return null;\n }\n setActiveStorage(storage);\n }\n\n // In `--mock` mode, seed the (in-memory) storage with a deterministic demo\n // dataset so the Dashboard renders full immediately (no real traffic).\n // Determinism: a fixed seed + a base timestamp captured once here. Only seed a\n // store we just created here, never an adopted/pre-existing one.\n if (mock && ownedHere) {\n const now = (opts.now ?? Date.now)();\n try {\n await seedMockData(storage, { now });\n } catch {\n // A seeding failure must never break server startup.\n }\n }\n\n // Initialize the live capture gate. `--mock` force-enables capture for the\n // session (full sample); otherwise resolve from env/file/db/default so the UI\n // toggle (and any env override) governs capture live.\n if (mock) {\n setCaptureGate({ enabled: true, sampleRate: 1 });\n } else {\n await refreshCaptureGate();\n }\n\n // Optional OTel export (M6): off by default, zero network/imports when unset.\n // Mock mode never exports (it's dev-only demo data); requires the explicit env\n // opt-in.\n const otel = mock ? null : await initOtel();\n\n const entry: McpMiddlewareEntry = {\n // \"request\" matches every (non-notification) request so non-tool methods\n // are counted too; the handler captures the tool name for tools/call.\n filter: \"request\",\n handler: createAnalyticsMiddleware(storage, opts.now, otel),\n };\n\n return { storage, entry, otel };\n}\n"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { analyticsEnabled, createAnalyticsMiddleware, installAnalytics, } from "./analytics.js";
|
|
4
|
+
import { getCaptureGate, setCaptureGate } from "./capture-gate.js";
|
|
4
5
|
import { getActiveStorage, serverLog, setActiveStorage } from "./log-sink.js";
|
|
5
6
|
import { MemoryStorageAdapter } from "./storage/memory.js";
|
|
6
7
|
/** Flush the fire-and-forget recordEvent/appendLog microtasks. */
|
|
@@ -38,6 +39,8 @@ describe("analyticsEnabled", () => {
|
|
|
38
39
|
}
|
|
39
40
|
});
|
|
40
41
|
});
|
|
42
|
+
/** A gate that always records (analytics on, full sample) — for capture tests. */
|
|
43
|
+
const onGate = () => ({ enabled: true, sampleRate: 1 });
|
|
41
44
|
describe("createAnalyticsMiddleware", () => {
|
|
42
45
|
let store;
|
|
43
46
|
beforeEach(async () => {
|
|
@@ -45,7 +48,9 @@ describe("createAnalyticsMiddleware", () => {
|
|
|
45
48
|
await store.init();
|
|
46
49
|
});
|
|
47
50
|
it("records a tool_call event on success with tool, ms, ok", async () => {
|
|
48
|
-
const mw = createAnalyticsMiddleware(store, fixedClock()
|
|
51
|
+
const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
|
|
52
|
+
gate: onGate,
|
|
53
|
+
});
|
|
49
54
|
const result = { content: [{ type: "text", text: "hi" }] };
|
|
50
55
|
const ret = await mw({ method: "tools/call", params: { name: "greet" } }, undefined, async () => result);
|
|
51
56
|
await flush();
|
|
@@ -62,14 +67,18 @@ describe("createAnalyticsMiddleware", () => {
|
|
|
62
67
|
expect(e?.error).toBeUndefined();
|
|
63
68
|
});
|
|
64
69
|
it("records ok=false when the result has isError", async () => {
|
|
65
|
-
const mw = createAnalyticsMiddleware(store, fixedClock()
|
|
70
|
+
const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
|
|
71
|
+
gate: onGate,
|
|
72
|
+
});
|
|
66
73
|
await mw({ method: "tools/call", params: { name: "boom" } }, undefined, async () => ({ isError: true, content: [] }));
|
|
67
74
|
await flush();
|
|
68
75
|
const [e] = await store.queryEvents({});
|
|
69
76
|
expect(e).toMatchObject({ tool: "boom", ok: false });
|
|
70
77
|
});
|
|
71
78
|
it("records the event AND rethrows on a thrown error", async () => {
|
|
72
|
-
const mw = createAnalyticsMiddleware(store, fixedClock()
|
|
79
|
+
const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
|
|
80
|
+
gate: onGate,
|
|
81
|
+
});
|
|
73
82
|
await expect(mw({ method: "tools/call", params: { name: "kaboom" } }, undefined, async () => {
|
|
74
83
|
throw new Error("nope");
|
|
75
84
|
})).rejects.toThrow("nope");
|
|
@@ -78,7 +87,9 @@ describe("createAnalyticsMiddleware", () => {
|
|
|
78
87
|
expect(e).toMatchObject({ tool: "kaboom", ok: false, error: "nope" });
|
|
79
88
|
});
|
|
80
89
|
it("captures the method but no tool for non-tools/call requests", async () => {
|
|
81
|
-
const mw = createAnalyticsMiddleware(store, fixedClock()
|
|
90
|
+
const mw = createAnalyticsMiddleware(store, fixedClock(), null, {
|
|
91
|
+
gate: onGate,
|
|
92
|
+
});
|
|
82
93
|
await mw({ method: "tools/list", params: {} }, undefined, async () => ({
|
|
83
94
|
tools: [],
|
|
84
95
|
}));
|
|
@@ -93,7 +104,9 @@ describe("createAnalyticsMiddleware", () => {
|
|
|
93
104
|
broken.recordEvent = async () => {
|
|
94
105
|
throw new Error("storage down");
|
|
95
106
|
};
|
|
96
|
-
const mw = createAnalyticsMiddleware(broken, fixedClock()
|
|
107
|
+
const mw = createAnalyticsMiddleware(broken, fixedClock(), null, {
|
|
108
|
+
gate: onGate,
|
|
109
|
+
});
|
|
97
110
|
const result = { content: [] };
|
|
98
111
|
await expect(mw({ method: "tools/call", params: { name: "x" } }, undefined, async () => result)).resolves.toBe(result);
|
|
99
112
|
await flush();
|
|
@@ -102,11 +115,13 @@ describe("createAnalyticsMiddleware", () => {
|
|
|
102
115
|
describe("installAnalytics gating", () => {
|
|
103
116
|
const originalAnalytics = process.env.ENPILINK_ANALYTICS;
|
|
104
117
|
const originalStorage = process.env.ENPILINK_STORAGE;
|
|
118
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
105
119
|
beforeEach(() => {
|
|
106
120
|
setActiveStorage(null);
|
|
107
121
|
});
|
|
108
122
|
afterEach(() => {
|
|
109
123
|
setActiveStorage(null);
|
|
124
|
+
setCaptureGate({ enabled: false, sampleRate: 1 });
|
|
110
125
|
if (originalAnalytics === undefined) {
|
|
111
126
|
delete process.env.ENPILINK_ANALYTICS;
|
|
112
127
|
}
|
|
@@ -119,33 +134,69 @@ describe("installAnalytics gating", () => {
|
|
|
119
134
|
else {
|
|
120
135
|
process.env.ENPILINK_STORAGE = originalStorage;
|
|
121
136
|
}
|
|
137
|
+
if (originalNodeEnv === undefined) {
|
|
138
|
+
delete process.env.NODE_ENV;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
142
|
+
}
|
|
122
143
|
});
|
|
123
|
-
it("
|
|
144
|
+
it("activates a default (memory) storage in dev even when analytics is OFF", async () => {
|
|
124
145
|
delete process.env.ENPILINK_ANALYTICS;
|
|
146
|
+
delete process.env.ENPILINK_STORAGE; // default = memory in dev
|
|
147
|
+
delete process.env.NODE_ENV; // dev
|
|
125
148
|
const result = await installAnalytics();
|
|
126
|
-
|
|
127
|
-
expect(
|
|
149
|
+
// Storage is activated (so the config/observability UI can persist) ...
|
|
150
|
+
expect(result).not.toBeNull();
|
|
151
|
+
expect(getActiveStorage()).toBe(result?.storage);
|
|
152
|
+
// ... but capture stays OFF until the runtime toggle / env enables it.
|
|
153
|
+
expect(getCaptureGate().enabled).toBe(false);
|
|
154
|
+
await result?.storage.close();
|
|
128
155
|
});
|
|
129
|
-
it("does NOT create a sqlite db file when
|
|
156
|
+
it("does NOT create a sqlite db file in dev when storage is the default (memory)", async () => {
|
|
130
157
|
delete process.env.ENPILINK_ANALYTICS;
|
|
131
|
-
process.env.ENPILINK_STORAGE =
|
|
158
|
+
delete process.env.ENPILINK_STORAGE; // default = memory => no file
|
|
159
|
+
delete process.env.NODE_ENV;
|
|
132
160
|
const dbPath = `${process.cwd()}/enpilink.db`;
|
|
133
161
|
const preexisting = existsSync(dbPath);
|
|
134
|
-
await installAnalytics();
|
|
162
|
+
const result = await installAnalytics();
|
|
135
163
|
if (!preexisting) {
|
|
136
164
|
expect(existsSync(dbPath)).toBe(false);
|
|
137
165
|
}
|
|
166
|
+
await result?.storage.close();
|
|
138
167
|
});
|
|
139
|
-
it("
|
|
168
|
+
it("activates NOTHING in prod when analytics is OFF (no admin)", async () => {
|
|
169
|
+
delete process.env.ENPILINK_ANALYTICS;
|
|
170
|
+
process.env.NODE_ENV = "production";
|
|
171
|
+
const result = await installAnalytics();
|
|
172
|
+
expect(result).toBeNull();
|
|
173
|
+
expect(getActiveStorage()).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
it("sets capture ON from env (env override) when ENPILINK_ANALYTICS=1", async () => {
|
|
140
176
|
process.env.ENPILINK_ANALYTICS = "1";
|
|
141
177
|
process.env.ENPILINK_STORAGE = "memory";
|
|
178
|
+
delete process.env.NODE_ENV;
|
|
142
179
|
const result = await installAnalytics();
|
|
143
180
|
expect(result).not.toBeNull();
|
|
144
181
|
expect(result?.entry.filter).toBe("request");
|
|
145
182
|
expect(typeof result?.entry.handler).toBe("function");
|
|
146
183
|
expect(getActiveStorage()).toBe(result?.storage);
|
|
184
|
+
// env override -> gate enabled regardless of any DB value.
|
|
185
|
+
expect(getCaptureGate().enabled).toBe(true);
|
|
147
186
|
await result?.storage.close();
|
|
148
187
|
});
|
|
188
|
+
it("REUSES an already-active storage and never double-inits", async () => {
|
|
189
|
+
const existing = new MemoryStorageAdapter();
|
|
190
|
+
await existing.init();
|
|
191
|
+
setActiveStorage(existing);
|
|
192
|
+
process.env.ENPILINK_ANALYTICS = "1";
|
|
193
|
+
delete process.env.NODE_ENV;
|
|
194
|
+
const result = await installAnalytics();
|
|
195
|
+
// Same instance reused; no second adapter created.
|
|
196
|
+
expect(result?.storage).toBe(existing);
|
|
197
|
+
expect(getActiveStorage()).toBe(existing);
|
|
198
|
+
await existing.close();
|
|
199
|
+
});
|
|
149
200
|
});
|
|
150
201
|
describe("serverLog capture", () => {
|
|
151
202
|
beforeEach(() => setActiveStorage(null));
|