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.
Files changed (49) hide show
  1. package/dist/server/analytics.d.ts +61 -21
  2. package/dist/server/analytics.js +107 -52
  3. package/dist/server/analytics.js.map +1 -1
  4. package/dist/server/analytics.test.js +63 -12
  5. package/dist/server/analytics.test.js.map +1 -1
  6. package/dist/server/capture-gate.d.ts +47 -0
  7. package/dist/server/capture-gate.js +39 -0
  8. package/dist/server/capture-gate.js.map +1 -0
  9. package/dist/server/capture-gate.test.d.ts +1 -0
  10. package/dist/server/capture-gate.test.js +124 -0
  11. package/dist/server/capture-gate.test.js.map +1 -0
  12. package/dist/server/config/config.test.js +201 -8
  13. package/dist/server/config/config.test.js.map +1 -1
  14. package/dist/server/config/index.d.ts +3 -2
  15. package/dist/server/config/index.js +3 -2
  16. package/dist/server/config/index.js.map +1 -1
  17. package/dist/server/config/presets.d.ts +36 -0
  18. package/dist/server/config/presets.js +46 -0
  19. package/dist/server/config/presets.js.map +1 -0
  20. package/dist/server/config/resolve.d.ts +42 -3
  21. package/dist/server/config/resolve.js +88 -8
  22. package/dist/server/config/resolve.js.map +1 -1
  23. package/dist/server/config/router.d.ts +22 -14
  24. package/dist/server/config/router.js +163 -51
  25. package/dist/server/config/router.js.map +1 -1
  26. package/dist/server/config/schema.d.ts +39 -1
  27. package/dist/server/config/schema.js +121 -0
  28. package/dist/server/config/schema.js.map +1 -1
  29. package/dist/server/index.d.ts +1 -1
  30. package/dist/server/index.js +1 -1
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/storage/memory.d.ts +1 -0
  33. package/dist/server/storage/memory.js +14 -0
  34. package/dist/server/storage/memory.js.map +1 -1
  35. package/dist/server/storage/memory.test.js +17 -0
  36. package/dist/server/storage/memory.test.js.map +1 -1
  37. package/dist/server/storage/postgres.d.ts +1 -0
  38. package/dist/server/storage/postgres.js +12 -0
  39. package/dist/server/storage/postgres.js.map +1 -1
  40. package/dist/server/storage/postgres.test.js +17 -0
  41. package/dist/server/storage/postgres.test.js.map +1 -1
  42. package/dist/server/storage/sqlite.d.ts +1 -0
  43. package/dist/server/storage/sqlite.js +21 -0
  44. package/dist/server/storage/sqlite.js.map +1 -1
  45. package/dist/server/storage/sqlite.test.js +17 -0
  46. package/dist/server/storage/sqlite.test.js.map +1 -1
  47. package/dist/server/storage/types.d.ts +6 -0
  48. package/dist/server/storage/types.js.map +1 -1
  49. 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). Opt-in, env-gated, zero overhead when off.
6
+ * Analytics + log capture (M2) now with a LIVE runtime toggle (bugfix).
6
7
  *
7
- * When enabled, a single {@link StorageAdapter} is resolved + `init()`ed at
8
- * server startup and shared in-process: the analytics middleware writes
9
- * `tool_call` events to it, the log sink mirrors server logs to it, and (M3)
10
- * the observability API reads from the very same instance via 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
- * Gating: OFF unless `ENPILINK_ANALYTICS` is `1` or `true` (case-insensitive).
14
- * When OFF, no adapter is resolved or initialized (so no `enpilink.db` is
15
- * created), no middleware is registered, and there is zero network activity.
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
- * records a `tool_call`-typed event (capturing the tool name for `tools/call`),
39
- * and ALWAYS swallows storage errors so a storage failure can never break or
40
- * slow a tool call. Recording is fire-and-forget (non-blocking).
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, ONLY when enabled (`ENPILINK_ANALYTICS`).
71
+ * Install analytics on a server.
45
72
  *
46
- * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`
47
- * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the
48
- * active storage for the log sink + `getActiveStorage()`, and returns the
49
- * built analytics middleware entry to splice into the chain.
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
- * When disabled: resolves/initializes NOTHING and returns `null` zero
52
- * overhead, zero network, no `enpilink.db` created.
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 disabled.
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;
@@ -1,4 +1,5 @@
1
- import { setActiveStorage } from "./log-sink.js";
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
- * records a `tool_call`-typed event (capturing the tool name for `tools/call`),
54
- * and ALWAYS swallows storage errors so a storage failure can never break or
55
- * slow a tool call. Recording is fire-and-forget (non-blocking).
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
- const ms = now() - start;
81
- const event = {
82
- ts: start,
83
- type: "tool_call",
84
- tool,
85
- method: request.method,
86
- ms,
87
- ok,
88
- error,
89
- };
90
- // Fire-and-forget; never block or throw into the request path.
91
- void recordSafely(storage, event);
92
- // Optional OTel export — guarded, synchronous, error-swallowing.
93
- if (otel) {
94
- try {
95
- otel.record(event);
96
- }
97
- catch {
98
- // OTel export must never break or slow a tool call.
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, ONLY when enabled (`ENPILINK_ANALYTICS`).
135
+ * Install analytics on a server.
115
136
  *
116
- * When enabled: resolves a {@link StorageAdapter} via `resolveStorageAdapter()`
117
- * (`ENPILINK_STORAGE` / `ENPILINK_DB_PATH`), `init()`s it, registers it as the
118
- * active storage for the log sink + `getActiveStorage()`, and returns the
119
- * built analytics middleware entry to splice into the chain.
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
- * When disabled: resolves/initializes NOTHING and returns `null` zero
122
- * overhead, zero network, no `enpilink.db` created.
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 disabled.
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 analytics for the session even when
128
- // the env-based gating is off, so demos work without setting both flags.
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 (!mock && !analyticsEnabled()) {
131
- return null;
132
- }
133
- let storage;
134
- try {
135
- storage = resolveSessionStorage(mock);
136
- await storage.init();
137
- }
138
- catch (err) {
139
- // Never let an analytics/storage failure break server startup.
140
- console.error("[enpilink] analytics disabled: storage init failed:", err instanceof Error ? err.message : err);
141
- return null;
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
- setActiveStorage(storage);
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
- if (mock) {
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
- // Initialized once here and fed by the middleware alongside storage. Mock mode
158
- // never exports (it's dev-only demo data); requires the explicit env opt-in.
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("returns null and creates no adapter when disabled", async () => {
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
- expect(result).toBeNull();
127
- expect(getActiveStorage()).toBeNull();
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 disabled", async () => {
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 = "sqlite";
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("resolves + inits + registers the active storage when enabled", async () => {
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));