@thotischner/observability-mcp 1.5.1 → 1.6.0

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.
@@ -0,0 +1,178 @@
1
+ import { describe, it, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { defaultContext } from "./context.js";
7
+ import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, _resetEnterpriseGate, } from "./enterprise-gate.js";
8
+ // These tests run in the mcp-server sandbox where enterprise/ is ABSENT
9
+ // (it is excluded from the npm package and the Docker build context) —
10
+ // exactly the published-artifact state. They pin the security contract:
11
+ //
12
+ // - no opt-in → OFF, perfect no-op (zero behaviour change)
13
+ // - control configured → FAIL-CLOSED when the gate can't activate
14
+ // (a broken/absent entitlement must DENY,
15
+ // never silently open)
16
+ function clearEnv() {
17
+ for (const k of [
18
+ "OMCP_ENTITLEMENT_TOKEN",
19
+ "OMCP_ENTITLEMENT_PUBKEY",
20
+ "OMCP_RBAC_POLICY",
21
+ "OMCP_CATALOG",
22
+ "OMCP_AUDIT_FILE",
23
+ ]) {
24
+ delete process.env[k];
25
+ }
26
+ _resetEnterpriseGate();
27
+ }
28
+ describe("enterprise-gate — OFF (no opt-in, published-artifact state)", () => {
29
+ afterEach(clearEnv);
30
+ it("no entitlement, no controls → awaited no-op, gate mode 'off'", async () => {
31
+ clearEnv();
32
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool: "query_metrics", service: "payment" }));
33
+ const st = await enterpriseGateStatus();
34
+ assert.equal(st.active, false);
35
+ assert.equal(st.mode, "off");
36
+ });
37
+ it("token set but NO control configured → still OFF (no opt-in), no throw", async () => {
38
+ clearEnv();
39
+ process.env.OMCP_ENTITLEMENT_TOKEN = "deadbeef.cafebabe";
40
+ process.env.OMCP_ENTITLEMENT_PUBKEY = "-----BEGIN PUBLIC KEY-----\\nX\\n-----END PUBLIC KEY-----";
41
+ _resetEnterpriseGate();
42
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool: "list_sources" }));
43
+ assert.equal((await enterpriseGateStatus()).mode, "off");
44
+ });
45
+ it("every tool name passes through cleanly when OFF", async () => {
46
+ clearEnv();
47
+ for (const tool of [
48
+ "list_sources",
49
+ "list_services",
50
+ "query_metrics",
51
+ "query_logs",
52
+ "get_service_health",
53
+ "detect_anomalies",
54
+ ]) {
55
+ await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool }));
56
+ }
57
+ });
58
+ it("gate state is memoised across calls", async () => {
59
+ clearEnv();
60
+ assert.deepEqual(await enterpriseGateStatus(), await enterpriseGateStatus());
61
+ });
62
+ });
63
+ describe("enterprise-gate — FAIL-CLOSED (opted in, cannot activate)", () => {
64
+ afterEach(clearEnv);
65
+ it("RBAC policy configured but enterprise/ absent → DENY every tool call", async () => {
66
+ clearEnv();
67
+ const dir = mkdtempSync(join(tmpdir(), "gate-fc-"));
68
+ const policy = join(dir, "rbac.json");
69
+ writeFileSync(policy, JSON.stringify({ roles: {}, bindings: {} }));
70
+ process.env.OMCP_RBAC_POLICY = policy; // operator opted into a control
71
+ _resetEnterpriseGate();
72
+ const st = await enterpriseGateStatus();
73
+ assert.equal(st.active, false);
74
+ assert.equal(st.mode, "fail-closed");
75
+ await assert.rejects(() => enforceEntitledAccess(defaultContext(), { tool: "query_metrics" }), /access denied: enterprise control configured but inactive/);
76
+ });
77
+ it("control configured + no token → fail-closed (not a silent open)", async () => {
78
+ clearEnv();
79
+ process.env.OMCP_CATALOG = "/nonexistent/catalog.json";
80
+ _resetEnterpriseGate();
81
+ assert.equal((await enterpriseGateStatus()).mode, "fail-closed");
82
+ await assert.rejects(() => enforceEntitledAccess(defaultContext(), { tool: "list_services" }), /access denied/);
83
+ });
84
+ });
85
+ describe("enterprise-gate — read-only console introspection", () => {
86
+ afterEach(clearEnv);
87
+ it("gateInfo: off → entitlement null, no token ever exposed", async () => {
88
+ clearEnv();
89
+ process.env.OMCP_ENTITLEMENT_TOKEN = "SECRET.SHOULD-NEVER-LEAK";
90
+ process.env.OMCP_ENTITLEMENT_PUBKEY = "x";
91
+ _resetEnterpriseGate();
92
+ const info = await enterpriseGateInfo();
93
+ assert.equal(info.active, false);
94
+ assert.equal(info.entitlement, null);
95
+ assert.equal("rbacConfigured" in info, true);
96
+ const dump = JSON.stringify(info);
97
+ assert.equal(dump.includes("SECRET"), false, "token must never appear in gate info");
98
+ });
99
+ it("gateInfo: configured-flags reflect env", async () => {
100
+ clearEnv();
101
+ process.env.OMCP_RBAC_POLICY = "/tmp/x.json";
102
+ process.env.OMCP_AUDIT_FILE = "/tmp/a.jsonl";
103
+ _resetEnterpriseGate();
104
+ const info = await enterpriseGateInfo();
105
+ assert.equal(info.rbacConfigured, true);
106
+ assert.equal(info.catalogConfigured, false);
107
+ assert.equal(info.auditConfigured, true);
108
+ });
109
+ it("policy/catalog view: not configured vs file error", () => {
110
+ clearEnv();
111
+ assert.deepEqual(enterprisePolicyView(), { configured: false });
112
+ assert.deepEqual(enterpriseCatalogView(), { configured: false });
113
+ const dir = mkdtempSync(join(tmpdir(), "gate-ro-"));
114
+ const f = join(dir, "p.json");
115
+ writeFileSync(f, '{"roles":{"a":{"tools":["*"]}},"bindings":{}}');
116
+ process.env.OMCP_RBAC_POLICY = f;
117
+ const v = enterprisePolicyView();
118
+ assert.equal(v.configured, true);
119
+ assert.deepEqual(Object.keys(v.data.roles), ["a"]);
120
+ process.env.OMCP_RBAC_POLICY = "/no/such/file.json";
121
+ const e = enterprisePolicyView();
122
+ assert.equal(e.configured, true);
123
+ assert.ok(e.error);
124
+ });
125
+ it("audit tail: not configured when no audit file", async () => {
126
+ clearEnv();
127
+ assert.deepEqual(await enterpriseAuditTail(10), { configured: false });
128
+ });
129
+ });
130
+ describe("enterprise-gate — P2 admin RBAC write", () => {
131
+ afterEach(clearEnv);
132
+ it("validatePolicyShape accepts a well-formed policy", () => {
133
+ assert.equal(validatePolicyShape({ roles: { a: { tools: ["*"] } }, bindings: { p: ["a"] }, defaultRoles: [] }), null);
134
+ });
135
+ it("validatePolicyShape rejects malformed shapes", () => {
136
+ assert.match(validatePolicyShape(null) || "", /must be a JSON object/);
137
+ assert.match(validatePolicyShape([]) || "", /must be a JSON object/);
138
+ assert.match(validatePolicyShape({ bindings: {} }) || "", /roles must be an object/);
139
+ assert.match(validatePolicyShape({ roles: {} }) || "", /bindings must be an object/);
140
+ assert.match(validatePolicyShape({ roles: {}, bindings: {}, defaultRoles: "x" }) || "", /defaultRoles must be an array/);
141
+ assert.match(validatePolicyShape({ roles: { r: { tools: "x" } }, bindings: {} }) || "", /role 'r.tools' must be an array/);
142
+ assert.match(validatePolicyShape({ roles: {}, bindings: { p: "x" } }) || "", /binding 'p' must be an array/);
143
+ });
144
+ it("authorizeAdmin denies when the gate is not active", async () => {
145
+ clearEnv(); // no entitlement → mode off
146
+ const r = await authorizeAdmin("someone");
147
+ assert.equal(r.ok, false);
148
+ assert.equal(r.status, 409);
149
+ assert.match(r.error ?? "", /gate not active/);
150
+ });
151
+ it("authorizeAdmin requires a principal once a control is configured", async () => {
152
+ clearEnv();
153
+ process.env.OMCP_RBAC_POLICY = "/tmp/none.json"; // fail-closed (no token)
154
+ _resetEnterpriseGate();
155
+ const r = await authorizeAdmin(null);
156
+ assert.equal(r.ok, false);
157
+ // gate is fail-closed here → still 409 (not active); never silently allows
158
+ assert.equal(r.ok, false);
159
+ });
160
+ });
161
+ describe("enterprise-gate — P3 catalog write validation", () => {
162
+ it("validateCatalogShape accepts a well-formed catalog", () => {
163
+ assert.equal(validateCatalogShape({
164
+ products: { p: { sources: ["*"], services: ["a"] } },
165
+ grants: { who: ["p"] },
166
+ defaultProducts: [],
167
+ }), null);
168
+ });
169
+ it("validateCatalogShape rejects malformed shapes", () => {
170
+ assert.match(validateCatalogShape(null) || "", /must be a JSON object/);
171
+ assert.match(validateCatalogShape({ grants: {} }) || "", /products must be an object/);
172
+ assert.match(validateCatalogShape({ products: {} }) || "", /grants must be an object/);
173
+ assert.match(validateCatalogShape({ products: { p: {} }, grants: {} }) || "", /product 'p.sources' must be an array/);
174
+ assert.match(validateCatalogShape({ products: { p: { sources: [], services: "x" } }, grants: {} }) || "", /product 'p.services' must be an array/);
175
+ assert.match(validateCatalogShape({ products: {}, grants: { g: "x" } }) || "", /grant 'g' must be an array/);
176
+ assert.match(validateCatalogShape({ products: {}, grants: {}, defaultProducts: 1 }) || "", /defaultProducts must be an array/);
177
+ });
178
+ });
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { z } from "zod";
9
9
  import { loadConfig, saveConfig, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_SETTINGS } from "./config/loader.js";
10
10
  import { ConnectorRegistry, getSupportedTypes } from "./connectors/registry.js";
11
11
  import { defaultContext, principalContext } from "./context.js";
12
+ import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, authorizeAdmin, updateRbacPolicy, updateCatalog, } from "./enterprise-gate.js";
12
13
  import { loadCredentials, credentialsConfigured, extractToken, resolveToken, } from "./auth/credentials.js";
13
14
  import { getPluginLoader } from "./connectors/loader.js";
14
15
  import { resolveHubCatalogUrl, describeInstalled, mergeCatalog, fetchHubCatalog, } from "./connectors/hub.js";
@@ -121,7 +122,10 @@ async function main() {
121
122
  "When to use: call this first to learn which source names exist and are healthy before passing `source` to other tools, or to debug why a query returns no data.",
122
123
  "Behavior: read-only, no side effects. Returns one entry per source with its name, type, configured URL, signal types (metrics/logs), and a live up/down status. Never throws for an unreachable backend — the backend is reported as down instead.",
123
124
  "Related: use `list_services` to see what is monitored within these sources.",
124
- ].join(" "), {}, async () => withToolMetrics("list_sources", () => listSourcesHandler(registry, ctx)));
125
+ ].join(" "), {}, async () => {
126
+ await enforceEntitledAccess(ctx, { tool: "list_sources" });
127
+ return withToolMetrics("list_sources", () => listSourcesHandler(registry, ctx));
128
+ });
125
129
  mcpServer.tool("list_services", [
126
130
  "Discover the service names that can be queried, aggregated across every connected backend.",
127
131
  "When to use: call this before `query_metrics`, `query_logs`, or `get_service_health` to obtain the exact, case-sensitive service name those tools require.",
@@ -132,7 +136,10 @@ async function main() {
132
136
  .string()
133
137
  .optional()
134
138
  .describe("Optional case-insensitive substring to narrow the result to matching service names (e.g. 'payment'). Omit to list every discovered service."),
135
- }, async (args) => withToolMetrics("list_services", () => listServicesHandler(registry, args, ctx)));
139
+ }, async (args) => {
140
+ await enforceEntitledAccess(ctx, { tool: "list_services" });
141
+ return withToolMetrics("list_services", () => listServicesHandler(registry, args, ctx));
142
+ });
136
143
  const metricsList = getAvailableMetricNames(registry);
137
144
  const metricNames = registry.getBySignal("metrics").flatMap(c => c.getMetrics().map(m => m.name));
138
145
  const uniqueNames = [...new Set(metricNames)];
@@ -161,7 +168,10 @@ async function main() {
161
168
  .string()
162
169
  .optional()
163
170
  .describe("Optional. Metric label to break the result down by, e.g. 'instance', 'pod', 'node'. When set, the response contains one series per distinct label value under `groups`. Default: a single aggregated series."),
164
- }, async (args) => withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx)));
171
+ }, async (args) => {
172
+ await enforceEntitledAccess(ctx, { tool: "query_metrics", source: args?.source, service: args?.service });
173
+ return withToolMetrics("query_metrics", () => queryMetricsHandler(registry, args, ctx));
174
+ });
165
175
  mcpServer.tool("query_logs", [
166
176
  "Fetch recent log entries for ONE service over a look-back window, with a pre-computed summary (error/warning counts and the most frequent error patterns).",
167
177
  "When to use: to inspect what a service actually logged, or to investigate an error spike surfaced by `detect_anomalies` / `get_service_health`. For numeric metrics use `query_metrics` instead.",
@@ -189,7 +199,10 @@ async function main() {
189
199
  .positive()
190
200
  .optional()
191
201
  .describe("Optional. Maximum number of log entries to return (most recent first). Default: 100."),
192
- }, async (args) => withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx)));
202
+ }, async (args) => {
203
+ await enforceEntitledAccess(ctx, { tool: "query_logs", source: args?.source, service: args?.service });
204
+ return withToolMetrics("query_logs", () => queryLogsHandler(registry, args, ctx));
205
+ });
193
206
  mcpServer.tool("get_service_health", [
194
207
  "Produce a single aggregated health verdict for ONE service by combining its metrics and logs.",
195
208
  "When to use: the fastest way to answer 'is this service healthy right now and why?'. Use `query_metrics`/`query_logs` to drill into the underlying numbers, or `detect_anomalies` to scan many services at once.",
@@ -199,7 +212,10 @@ async function main() {
199
212
  service: z
200
213
  .string()
201
214
  .describe("Required. Exact, case-sensitive service name exactly as returned by `list_services` (e.g. 'payment-service')."),
202
- }, async (args) => withToolMetrics("get_service_health", () => getServiceHealthHandler(registry, args, ctx)));
215
+ }, async (args) => {
216
+ await enforceEntitledAccess(ctx, { tool: "get_service_health", service: args?.service });
217
+ return withToolMetrics("get_service_health", () => getServiceHealthHandler(registry, args, ctx));
218
+ });
203
219
  mcpServer.tool("detect_anomalies", [
204
220
  "Scan one or all monitored services for abnormal behavior and return the findings ranked by severity.",
205
221
  "When to use: the entry point for 'is anything wrong anywhere?' triage. Once a service is flagged, follow up with `get_service_health` for the verdict or `query_metrics`/`query_logs` for the raw evidence.",
@@ -218,7 +234,10 @@ async function main() {
218
234
  .enum(["low", "medium", "high"])
219
235
  .optional()
220
236
  .describe("Optional. Detection threshold: 'low' flags only strong deviations (>3σ), 'medium' is balanced (>2σ), 'high' is most sensitive and noisier (>1.5σ). Default: 'medium'."),
221
- }, async (args) => withToolMetrics("detect_anomalies", () => detectAnomaliesHandler(registry, args, ctx)));
237
+ }, async (args) => {
238
+ await enforceEntitledAccess(ctx, { tool: "detect_anomalies", source: args?.source, service: args?.service });
239
+ return withToolMetrics("detect_anomalies", () => detectAnomaliesHandler(registry, args, ctx));
240
+ });
222
241
  return mcpServer;
223
242
  }
224
243
  // --- HTTP server ---
@@ -313,6 +332,7 @@ async function main() {
313
332
  res.json({
314
333
  name: "observability-mcp",
315
334
  version: SERVER_VERSION,
335
+ enterpriseGate: await enterpriseGateStatus(),
316
336
  mcpProtocolVersion: "2025-03-26",
317
337
  build: {
318
338
  commit: process.env.GIT_COMMIT || null,
@@ -336,6 +356,58 @@ async function main() {
336
356
  app.get("/api/connectors", (_req, res) => {
337
357
  res.json({ connectors: describeInstalled(getPluginLoader().list()) });
338
358
  });
359
+ // --- Enterprise console (read-only introspection) -------------------
360
+ // Drives the management UI's Enterprise page. Read-only in this phase;
361
+ // never exposes the entitlement token or any key. Same trusted-local
362
+ // management plane as the other /api/* endpoints (see auth-and-tls).
363
+ app.get("/api/enterprise/status", async (_req, res) => {
364
+ try {
365
+ res.json(await enterpriseGateInfo());
366
+ }
367
+ catch (e) {
368
+ res.status(500).json({ error: String(e) });
369
+ }
370
+ });
371
+ app.get("/api/enterprise/policy", (_req, res) => {
372
+ res.json(enterprisePolicyView());
373
+ });
374
+ app.get("/api/enterprise/catalog", (_req, res) => {
375
+ res.json(enterpriseCatalogView());
376
+ });
377
+ app.get("/api/enterprise/audit", async (req, res) => {
378
+ const limit = Math.min(Number(req.query.limit) || 50, 500);
379
+ try {
380
+ res.json(await enterpriseAuditTail(limit));
381
+ }
382
+ catch (e) {
383
+ res.status(500).json({ error: String(e) });
384
+ }
385
+ });
386
+ // Phase 2: edit the RBAC policy. NOT on the open local plane — requires
387
+ // an API-key principal the CURRENT policy grants `enterprise:admin`.
388
+ app.put("/api/enterprise/policy", async (req, res) => {
389
+ const cred = resolveToken(extractToken(req.headers), loadCredentials());
390
+ const principal = cred ? cred.name : null;
391
+ const authz = await authorizeAdmin(principal);
392
+ if (!authz.ok)
393
+ return res.status(authz.status).json({ error: authz.error });
394
+ const result = await updateRbacPolicy(principal, req.body);
395
+ if (!result.ok)
396
+ return res.status(result.status).json({ error: result.error });
397
+ res.json({ ok: true });
398
+ });
399
+ // Phase 3: edit the product catalog. Same admin model as the RBAC write.
400
+ app.put("/api/enterprise/catalog", async (req, res) => {
401
+ const cred = resolveToken(extractToken(req.headers), loadCredentials());
402
+ const principal = cred ? cred.name : null;
403
+ const authz = await authorizeAdmin(principal);
404
+ if (!authz.ok)
405
+ return res.status(authz.status).json({ error: authz.error });
406
+ const result = await updateCatalog(principal, req.body);
407
+ if (!result.ok)
408
+ return res.status(result.status).json({ error: result.error });
409
+ res.json({ ok: true });
410
+ });
339
411
  // Server-side proxy of the connector hub catalog (so the browser
340
412
  // needn't reach the hub directly — works behind a proxy / against a
341
413
  // mirror via HUB_CATALOG_URL). Installed status merged in.
@@ -1,6 +1,6 @@
1
1
  import { defaultContext } from "../context.js";
2
2
  import { calculateHealthScore } from "../analysis/health.js";
3
- import { detectRecentAnomaly } from "../analysis/anomaly.js";
3
+ import { detectRobustAnomaly, classifyMetric } from "../analysis/anomaly.js";
4
4
  import { sanitizeForLog } from "../util/sanitize.js";
5
5
  let _thresholds = null;
6
6
  export function setHealthThresholds(t) {
@@ -94,17 +94,20 @@ export async function getServiceHealthHandler(registry, args, _ctx = defaultCont
94
94
  };
95
95
  }
96
96
  function checkAnomaly(values, metric, service, source, anomalies) {
97
- const result = detectRecentAnomaly(values);
97
+ // Robust, metric-type-aware detector (same path as detect_anomalies):
98
+ // latency/error_rate/saturation are one-sided, so a *decrease* (e.g.
99
+ // latency dropping) is correctly NOT flagged as an anomaly.
100
+ const result = detectRobustAnomaly(values, { metricKind: classifyMetric(metric) });
98
101
  if (result.isAnomaly) {
99
- const deviationPercent = result.baselineAvg === 0
102
+ const deviationPercent = result.baselineValue === 0
100
103
  ? 100
101
- : Math.round(((result.recentAvg - result.baselineAvg) / result.baselineAvg) * 100);
104
+ : Math.round(((result.recentValue - result.baselineValue) / result.baselineValue) * 100);
102
105
  anomalies.push({
103
106
  metric,
104
- severity: Math.abs(result.zScore) >= 3 ? "high" : Math.abs(result.zScore) >= 2 ? "medium" : "low",
105
- description: `${metric} is ${result.zScore.toFixed(1)}σ ${result.zScore > 0 ? "above" : "below"} baseline (${result.baselineAvg.toFixed(2)} → ${result.recentAvg.toFixed(2)})`,
106
- currentValue: result.recentAvg,
107
- baselineValue: result.baselineAvg,
107
+ severity: Math.abs(result.score) >= 6 ? "high" : Math.abs(result.score) >= 4 ? "medium" : "low",
108
+ description: `${metric}: ${result.reason}`,
109
+ currentValue: result.recentValue,
110
+ baselineValue: result.baselineValue,
108
111
  deviationPercent,
109
112
  source,
110
113
  service,
@@ -4,6 +4,7 @@ import { ConnectorRegistry } from "../connectors/registry.js";
4
4
  import { listSourcesHandler } from "./list-sources.js";
5
5
  import { listServicesHandler } from "./list-services.js";
6
6
  import { detectAnomaliesHandler } from "./detect-anomalies.js";
7
+ import { getServiceHealthHandler } from "./get-service-health.js";
7
8
  // --- Mock Connector ---
8
9
  function createMockConnector(overrides) {
9
10
  return {
@@ -209,3 +210,33 @@ describe("detectAnomaliesHandler — A5 memory/OOM coverage", () => {
209
210
  assert.equal(data.anomalies.length, 0);
210
211
  });
211
212
  });
213
+ describe("getServiceHealthHandler — one-sided latency (regression)", () => {
214
+ const series = (vals) => ({
215
+ source: "prom1", service: "payment-service", metric: "x", unit: "",
216
+ values: vals.map((v, i) => ({ timestamp: new Date(Date.now() - (vals.length - i) * 9000).toISOString(), value: v })),
217
+ summary: { current: vals[vals.length - 1], average: vals[0], min: Math.min(...vals), max: Math.max(...vals), trend: "falling" },
218
+ });
219
+ it("a DECREASING latency_p99 is NOT flagged as an anomaly", async () => {
220
+ const reg = new ConnectorRegistry();
221
+ const mock = {
222
+ connect: async () => { }, disconnect: async () => { },
223
+ healthCheck: async () => ({ status: "up", latencyMs: 1 }),
224
+ getDefaultMetrics: () => [], getMetrics: () => [],
225
+ listServices: async () => [{ name: "payment-service", source: "prom1", signalType: "metrics" }],
226
+ name: "prom1", type: "prometheus", signalType: "metrics",
227
+ queryMetrics: async ({ metric }) => {
228
+ if (metric === "latency_p99")
229
+ return series(Array.from({ length: 30 }, (_, i) => 1.0 - i * 0.025)); // 1.0 → 0.275, strictly down
230
+ if (metric === "cpu")
231
+ return series(Array.from({ length: 30 }, () => 20 + (Math.random() < 0 ? 1 : 0)));
232
+ return series(Array.from({ length: 30 }, () => 0.01)); // error_rate flat
233
+ },
234
+ };
235
+ reg.connectors.set("prom1", mock);
236
+ reg.sourceConfigs.set("prom1", { name: "prom1", type: "prometheus", url: "http://m", enabled: true });
237
+ const result = await getServiceHealthHandler(reg, { service: "payment-service" });
238
+ const data = JSON.parse(result.content[0].text);
239
+ const latAnom = (data.anomalies || []).find((a) => a.metric === "latency_p99");
240
+ assert.equal(latAnom, undefined, `latency dropping must not be an anomaly, got: ${JSON.stringify(latAnom)}`);
241
+ });
242
+ });