@thotischner/observability-mcp 1.4.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.
- package/dist/analysis/anomaly.d.ts +89 -0
- package/dist/analysis/anomaly.js +235 -0
- package/dist/analysis/anomaly.test.js +149 -1
- package/dist/analysis/backtest.d.ts +31 -0
- package/dist/analysis/backtest.js +206 -0
- package/dist/analysis/backtest.test.d.ts +1 -0
- package/dist/analysis/backtest.test.js +34 -0
- package/dist/analysis/correlator.d.ts +35 -0
- package/dist/analysis/correlator.js +95 -0
- package/dist/analysis/correlator.test.js +60 -1
- package/dist/analysis/health.d.ts +2 -3
- package/dist/analysis/index.d.ts +32 -0
- package/dist/analysis/index.js +29 -0
- package/dist/analysis/library.test.d.ts +1 -0
- package/dist/analysis/library.test.js +44 -0
- package/dist/auth/credentials.d.ts +29 -0
- package/dist/auth/credentials.js +76 -0
- package/dist/auth/credentials.test.d.ts +1 -0
- package/dist/auth/credentials.test.js +57 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +18 -0
- package/dist/enterprise-gate.d.ts +132 -0
- package/dist/enterprise-gate.js +510 -0
- package/dist/enterprise-gate.test.d.ts +1 -0
- package/dist/enterprise-gate.test.js +178 -0
- package/dist/index.js +125 -44
- package/dist/net/egress-policy.d.ts +31 -0
- package/dist/net/egress-policy.js +37 -0
- package/dist/net/egress-policy.test.d.ts +1 -0
- package/dist/net/egress-policy.test.js +52 -0
- package/dist/tools/context-seam.test.d.ts +1 -0
- package/dist/tools/context-seam.test.js +23 -0
- package/dist/tools/detect-anomalies.d.ts +2 -1
- package/dist/tools/detect-anomalies.js +47 -11
- package/dist/tools/get-service-health.d.ts +2 -1
- package/dist/tools/get-service-health.js +13 -9
- package/dist/tools/handlers.test.js +104 -0
- package/dist/tools/list-services.d.ts +2 -1
- package/dist/tools/list-services.js +2 -1
- package/dist/tools/list-sources.d.ts +2 -1
- package/dist/tools/list-sources.js +2 -1
- package/dist/tools/query-logs.d.ts +2 -1
- package/dist/tools/query-logs.js +2 -1
- package/dist/tools/query-metrics.d.ts +2 -1
- package/dist/tools/query-metrics.js +9 -1
- package/dist/ui/index.html +1510 -67
- package/package.json +10 -2
|
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { ConnectorRegistry } from "../connectors/registry.js";
|
|
4
4
|
import { listSourcesHandler } from "./list-sources.js";
|
|
5
5
|
import { listServicesHandler } from "./list-services.js";
|
|
6
|
+
import { detectAnomaliesHandler } from "./detect-anomalies.js";
|
|
7
|
+
import { getServiceHealthHandler } from "./get-service-health.js";
|
|
6
8
|
// --- Mock Connector ---
|
|
7
9
|
function createMockConnector(overrides) {
|
|
8
10
|
return {
|
|
@@ -136,3 +138,105 @@ describe("listServicesHandler", () => {
|
|
|
136
138
|
assert.equal(data.total, 0);
|
|
137
139
|
});
|
|
138
140
|
});
|
|
141
|
+
describe("detectAnomaliesHandler — A5 memory/OOM coverage", () => {
|
|
142
|
+
const flatMemory = () => ({
|
|
143
|
+
source: "prom1", service: "payment-service", metric: "memory", unit: "bytes",
|
|
144
|
+
values: Array.from({ length: 40 }, (_, i) => ({
|
|
145
|
+
timestamp: new Date(Date.now() - (40 - i) * 9000).toISOString(),
|
|
146
|
+
value: 1.3e8 + (i % 3) * 1e6, // noisy, no trend → no metric anomaly
|
|
147
|
+
})),
|
|
148
|
+
summary: { current: 1.3e8, average: 1.3e8, min: 1.28e8, max: 1.33e8, trend: "stable" },
|
|
149
|
+
});
|
|
150
|
+
it("scans the memory metric (now in KEY_METRICS)", async () => {
|
|
151
|
+
const requested = [];
|
|
152
|
+
const reg = createRegistryWithMocks([
|
|
153
|
+
createMockConnector({
|
|
154
|
+
name: "prom1", type: "prometheus", signalType: "metrics",
|
|
155
|
+
listServices: async () => [{ name: "payment-service", source: "prom1", signalType: "metrics" }],
|
|
156
|
+
queryMetrics: async ({ metric }) => {
|
|
157
|
+
requested.push(metric);
|
|
158
|
+
return flatMemory();
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
]);
|
|
162
|
+
await detectAnomaliesHandler(reg, {});
|
|
163
|
+
assert.ok(requested.includes("memory"), `memory not scanned; got ${requested.join(",")}`);
|
|
164
|
+
});
|
|
165
|
+
it("flags a warn-level OOM log pattern below the error-rate gate", async () => {
|
|
166
|
+
const reg = createRegistryWithMocks([
|
|
167
|
+
createMockConnector({
|
|
168
|
+
name: "prom1", type: "prometheus", signalType: "metrics",
|
|
169
|
+
listServices: async () => [{ name: "payment-service", source: "prom1", signalType: "metrics" }],
|
|
170
|
+
queryMetrics: async () => flatMemory(),
|
|
171
|
+
}),
|
|
172
|
+
createMockConnector({
|
|
173
|
+
name: "loki1", type: "loki", signalType: "logs",
|
|
174
|
+
queryLogs: async () => ({
|
|
175
|
+
source: "loki1", service: "payment-service", entries: [],
|
|
176
|
+
// Only 4 warn-level lines: errorCount below the >5 gate, ratio tiny.
|
|
177
|
+
summary: {
|
|
178
|
+
total: 800, errorCount: 4, warnCount: 4,
|
|
179
|
+
topPatterns: ["OutOfMemoryWarning: heap usage exceeding threshold (4x)"],
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
]);
|
|
184
|
+
const result = await detectAnomaliesHandler(reg, {});
|
|
185
|
+
const data = JSON.parse(result.content[0].text);
|
|
186
|
+
const crit = data.anomalies.find((a) => a.metric === "log_critical_pattern");
|
|
187
|
+
assert.ok(crit, "expected a log_critical_pattern anomaly for the OOM warning");
|
|
188
|
+
assert.equal(crit.service, "payment-service");
|
|
189
|
+
assert.equal(crit.severity, "high");
|
|
190
|
+
assert.equal(data.rootCause.ranked[0].service, "payment-service");
|
|
191
|
+
assert.notEqual(data.summary, "All services healthy — no anomalies detected.");
|
|
192
|
+
});
|
|
193
|
+
it("does not flag benign warn patterns", async () => {
|
|
194
|
+
const reg = createRegistryWithMocks([
|
|
195
|
+
createMockConnector({
|
|
196
|
+
name: "prom1", type: "prometheus", signalType: "metrics",
|
|
197
|
+
listServices: async () => [{ name: "order-service", source: "prom1", signalType: "metrics" }],
|
|
198
|
+
queryMetrics: async () => flatMemory(),
|
|
199
|
+
}),
|
|
200
|
+
createMockConnector({
|
|
201
|
+
name: "loki1", type: "loki", signalType: "logs",
|
|
202
|
+
queryLogs: async () => ({
|
|
203
|
+
source: "loki1", service: "order-service", entries: [],
|
|
204
|
+
summary: { total: 500, errorCount: 1, warnCount: 2, topPatterns: ["cache miss for key user:42"] },
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
const result = await detectAnomaliesHandler(reg, {});
|
|
209
|
+
const data = JSON.parse(result.content[0].text);
|
|
210
|
+
assert.equal(data.anomalies.length, 0);
|
|
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
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectorRegistry } from "../connectors/registry.js";
|
|
2
|
+
import { type RequestContext } from "../context.js";
|
|
2
3
|
export declare const listServicesDefinition: {
|
|
3
4
|
name: "list_services";
|
|
4
5
|
description: string;
|
|
@@ -14,7 +15,7 @@ export declare const listServicesDefinition: {
|
|
|
14
15
|
};
|
|
15
16
|
export declare function listServicesHandler(registry: ConnectorRegistry, args: {
|
|
16
17
|
filter?: string;
|
|
17
|
-
}): Promise<{
|
|
18
|
+
}, _ctx?: RequestContext): Promise<{
|
|
18
19
|
content: {
|
|
19
20
|
type: "text";
|
|
20
21
|
text: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultContext } from "../context.js";
|
|
1
2
|
export const listServicesDefinition = {
|
|
2
3
|
name: "list_services",
|
|
3
4
|
description: "List all monitored services discovered across all connected backends. Returns service names, their data sources, and signal types (metrics/logs).",
|
|
@@ -11,7 +12,7 @@ export const listServicesDefinition = {
|
|
|
11
12
|
},
|
|
12
13
|
},
|
|
13
14
|
};
|
|
14
|
-
export async function listServicesHandler(registry, args) {
|
|
15
|
+
export async function listServicesHandler(registry, args, _ctx = defaultContext()) {
|
|
15
16
|
const connectors = registry.getAll();
|
|
16
17
|
const allServices = [];
|
|
17
18
|
for (const connector of connectors) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectorRegistry } from "../connectors/registry.js";
|
|
2
|
+
import { type RequestContext } from "../context.js";
|
|
2
3
|
export declare const listSourcesDefinition: {
|
|
3
4
|
name: "list_sources";
|
|
4
5
|
description: string;
|
|
@@ -7,7 +8,7 @@ export declare const listSourcesDefinition: {
|
|
|
7
8
|
properties: {};
|
|
8
9
|
};
|
|
9
10
|
};
|
|
10
|
-
export declare function listSourcesHandler(registry: ConnectorRegistry): Promise<{
|
|
11
|
+
export declare function listSourcesHandler(registry: ConnectorRegistry, _ctx?: RequestContext): Promise<{
|
|
11
12
|
content: {
|
|
12
13
|
type: "text";
|
|
13
14
|
text: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultContext } from "../context.js";
|
|
1
2
|
export const listSourcesDefinition = {
|
|
2
3
|
name: "list_sources",
|
|
3
4
|
description: "List all configured observability backends and their connection status. Use this to discover what data sources are available.",
|
|
@@ -6,7 +7,7 @@ export const listSourcesDefinition = {
|
|
|
6
7
|
properties: {},
|
|
7
8
|
},
|
|
8
9
|
};
|
|
9
|
-
export async function listSourcesHandler(registry) {
|
|
10
|
+
export async function listSourcesHandler(registry, _ctx = defaultContext()) {
|
|
10
11
|
const healthResults = await registry.healthCheckAll();
|
|
11
12
|
const connectors = registry.getAll();
|
|
12
13
|
const sources = connectors.map((c) => ({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectorRegistry } from "../connectors/registry.js";
|
|
2
|
+
import { type RequestContext } from "../context.js";
|
|
2
3
|
export declare const queryLogsDefinition: {
|
|
3
4
|
name: "query_logs";
|
|
4
5
|
description: string;
|
|
@@ -35,7 +36,7 @@ export declare function queryLogsHandler(registry: ConnectorRegistry, args: {
|
|
|
35
36
|
duration?: string;
|
|
36
37
|
level?: string;
|
|
37
38
|
limit?: number;
|
|
38
|
-
}): Promise<{
|
|
39
|
+
}, _ctx?: RequestContext): Promise<{
|
|
39
40
|
content: {
|
|
40
41
|
type: "text";
|
|
41
42
|
text: string;
|
package/dist/tools/query-logs.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultContext } from "../context.js";
|
|
1
2
|
import { validateDuration, validateServiceName, errorResponse } from "./validation.js";
|
|
2
3
|
export const queryLogsDefinition = {
|
|
3
4
|
name: "query_logs",
|
|
@@ -29,7 +30,7 @@ export const queryLogsDefinition = {
|
|
|
29
30
|
required: ["service"],
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
|
-
export async function queryLogsHandler(registry, args) {
|
|
33
|
+
export async function queryLogsHandler(registry, args, _ctx = defaultContext()) {
|
|
33
34
|
const svcErr = validateServiceName(args.service);
|
|
34
35
|
if (svcErr)
|
|
35
36
|
return errorResponse(svcErr);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConnectorRegistry } from "../connectors/registry.js";
|
|
2
|
+
import { type RequestContext } from "../context.js";
|
|
2
3
|
export declare const queryMetricsDefinition: {
|
|
3
4
|
name: "query_metrics";
|
|
4
5
|
description: string;
|
|
@@ -35,7 +36,7 @@ export declare function queryMetricsHandler(registry: ConnectorRegistry, args: {
|
|
|
35
36
|
duration?: string;
|
|
36
37
|
source?: string;
|
|
37
38
|
groupBy?: string;
|
|
38
|
-
}): Promise<{
|
|
39
|
+
}, _ctx?: RequestContext): Promise<{
|
|
39
40
|
content: {
|
|
40
41
|
type: "text";
|
|
41
42
|
text: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultContext } from "../context.js";
|
|
1
2
|
import { validateDuration, validateMetricName, validateServiceName, errorResponse } from "./validation.js";
|
|
2
3
|
export const queryMetricsDefinition = {
|
|
3
4
|
name: "query_metrics",
|
|
@@ -29,7 +30,14 @@ export const queryMetricsDefinition = {
|
|
|
29
30
|
required: ["service", "metric"],
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
|
-
export async function queryMetricsHandler(registry, args) {
|
|
33
|
+
export async function queryMetricsHandler(registry, args, _ctx = defaultContext()) {
|
|
34
|
+
// Coarse single-tenant source scoping: if the principal is restricted to a
|
|
35
|
+
// source allow-list, deny an explicit out-of-scope source.
|
|
36
|
+
if (_ctx.allowedSources &&
|
|
37
|
+
args.source &&
|
|
38
|
+
!_ctx.allowedSources.includes(args.source)) {
|
|
39
|
+
return errorResponse(`forbidden: source "${args.source}" is not in your allowed sources`);
|
|
40
|
+
}
|
|
33
41
|
const svcErr = validateServiceName(args.service);
|
|
34
42
|
if (svcErr)
|
|
35
43
|
return errorResponse(svcErr);
|