@thotischner/observability-mcp 1.0.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/config/sources.yaml +45 -0
- package/dist/analysis/anomaly.d.ts +24 -0
- package/dist/analysis/anomaly.js +50 -0
- package/dist/analysis/anomaly.test.d.ts +1 -0
- package/dist/analysis/anomaly.test.js +87 -0
- package/dist/analysis/correlator.d.ts +7 -0
- package/dist/analysis/correlator.js +31 -0
- package/dist/analysis/correlator.test.d.ts +1 -0
- package/dist/analysis/correlator.test.js +53 -0
- package/dist/analysis/health.d.ts +19 -0
- package/dist/analysis/health.js +34 -0
- package/dist/analysis/health.test.d.ts +1 -0
- package/dist/analysis/health.test.js +70 -0
- package/dist/config/loader.d.ts +5 -0
- package/dist/config/loader.js +81 -0
- package/dist/config/loader.test.d.ts +1 -0
- package/dist/config/loader.test.js +163 -0
- package/dist/connectors/interface.d.ts +17 -0
- package/dist/connectors/interface.js +1 -0
- package/dist/connectors/loki.d.ts +25 -0
- package/dist/connectors/loki.js +182 -0
- package/dist/connectors/loki.test.d.ts +1 -0
- package/dist/connectors/loki.test.js +111 -0
- package/dist/connectors/prometheus.d.ts +28 -0
- package/dist/connectors/prometheus.js +196 -0
- package/dist/connectors/prometheus.test.d.ts +1 -0
- package/dist/connectors/prometheus.test.js +103 -0
- package/dist/connectors/registry.d.ts +18 -0
- package/dist/connectors/registry.js +90 -0
- package/dist/connectors/registry.test.d.ts +1 -0
- package/dist/connectors/registry.test.js +93 -0
- package/dist/connectors/tls.d.ts +7 -0
- package/dist/connectors/tls.js +25 -0
- package/dist/connectors/tls.test.d.ts +1 -0
- package/dist/connectors/tls.test.js +99 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +421 -0
- package/dist/tools/detect-anomalies.d.ts +33 -0
- package/dist/tools/detect-anomalies.js +137 -0
- package/dist/tools/get-service-health.d.ts +25 -0
- package/dist/tools/get-service-health.js +111 -0
- package/dist/tools/handlers.test.d.ts +1 -0
- package/dist/tools/handlers.test.js +138 -0
- package/dist/tools/list-services.d.ts +22 -0
- package/dist/tools/list-services.js +57 -0
- package/dist/tools/list-sources.d.ts +15 -0
- package/dist/tools/list-sources.js +27 -0
- package/dist/tools/query-logs.d.ts +49 -0
- package/dist/tools/query-logs.js +93 -0
- package/dist/tools/query-metrics.d.ts +44 -0
- package/dist/tools/query-metrics.js +91 -0
- package/dist/tools/validation.d.ts +17 -0
- package/dist/tools/validation.js +45 -0
- package/dist/tools/validation.test.d.ts +1 -0
- package/dist/tools/validation.test.js +84 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.js +1 -0
- package/dist/ui/index.html +675 -0
- package/package.json +35 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { buildTlsAgent } from "./tls.js";
|
|
2
|
+
const DEFAULT_PROMETHEUS_METRICS = [
|
|
3
|
+
{ name: "cpu", query: 'service_cpu_usage_percent{job="{{service}}"}', unit: "percent", description: "CPU usage percentage" },
|
|
4
|
+
{ name: "memory", query: 'service_memory_usage_bytes{job="{{service}}"}', unit: "bytes", description: "Memory usage in bytes" },
|
|
5
|
+
{ name: "error_rate", query: 'rate(http_requests_total{job="{{service}}",status=~"5.."}[1m])', unit: "req/s", description: "HTTP 5xx error rate" },
|
|
6
|
+
{ name: "request_rate", query: 'rate(http_requests_total{job="{{service}}"}[1m])', unit: "req/s", description: "Total HTTP request rate" },
|
|
7
|
+
{ name: "latency_p99", query: 'histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="{{service}}"}[1m]))', unit: "seconds", description: "99th percentile latency" },
|
|
8
|
+
{ name: "latency_p50", query: 'histogram_quantile(0.50, rate(http_request_duration_seconds_bucket{job="{{service}}"}[1m]))', unit: "seconds", description: "50th percentile latency" },
|
|
9
|
+
{ name: "latency_avg", query: 'rate(http_request_duration_seconds_sum{job="{{service}}"}[1m]) / rate(http_request_duration_seconds_count{job="{{service}}"}[1m])', unit: "seconds", description: "Average request latency" },
|
|
10
|
+
];
|
|
11
|
+
export class PrometheusConnector {
|
|
12
|
+
type = "prometheus";
|
|
13
|
+
signalType = "metrics";
|
|
14
|
+
name = "";
|
|
15
|
+
baseUrl = "";
|
|
16
|
+
auth;
|
|
17
|
+
tlsAgent;
|
|
18
|
+
metrics = [];
|
|
19
|
+
async connect(config) {
|
|
20
|
+
this.name = config.name;
|
|
21
|
+
this.baseUrl = config.url.replace(/\/$/, "");
|
|
22
|
+
this.auth = config.auth;
|
|
23
|
+
this.tlsAgent = buildTlsAgent(config);
|
|
24
|
+
// Use source-level metrics if provided, otherwise connector defaults
|
|
25
|
+
this.metrics = config.metrics && config.metrics.length > 0
|
|
26
|
+
? config.metrics
|
|
27
|
+
: [...DEFAULT_PROMETHEUS_METRICS];
|
|
28
|
+
}
|
|
29
|
+
getDefaultMetrics() {
|
|
30
|
+
return DEFAULT_PROMETHEUS_METRICS;
|
|
31
|
+
}
|
|
32
|
+
getMetrics() {
|
|
33
|
+
return this.metrics;
|
|
34
|
+
}
|
|
35
|
+
setMetrics(metrics) {
|
|
36
|
+
this.metrics = metrics;
|
|
37
|
+
}
|
|
38
|
+
async healthCheck() {
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${this.baseUrl}/-/ready`, this.fetchOptions());
|
|
42
|
+
return {
|
|
43
|
+
status: res.ok ? "up" : "down",
|
|
44
|
+
latencyMs: Date.now() - start,
|
|
45
|
+
message: res.ok ? "Prometheus is ready" : `HTTP ${res.status}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return { status: "down", latencyMs: Date.now() - start, message: String(err) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async disconnect() { }
|
|
53
|
+
async listServices() {
|
|
54
|
+
const data = await this.apiGet("/api/v1/targets");
|
|
55
|
+
const targets = data?.data?.activeTargets || [];
|
|
56
|
+
const services = new Map();
|
|
57
|
+
for (const t of targets) {
|
|
58
|
+
const name = t.labels?.service || t.labels?.job || t.discoveredLabels?.__address__ || "unknown";
|
|
59
|
+
if (!services.has(name)) {
|
|
60
|
+
services.set(name, {
|
|
61
|
+
name,
|
|
62
|
+
source: this.name,
|
|
63
|
+
signalType: "metrics",
|
|
64
|
+
labels: t.labels,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Array.from(services.values());
|
|
69
|
+
}
|
|
70
|
+
async listAvailableMetrics(_service) {
|
|
71
|
+
const data = await this.apiGet("/api/v1/metadata");
|
|
72
|
+
if (!data?.data)
|
|
73
|
+
return [];
|
|
74
|
+
const metrics = [];
|
|
75
|
+
for (const [name, entries] of Object.entries(data.data)) {
|
|
76
|
+
const entry = entries[0];
|
|
77
|
+
if (entry) {
|
|
78
|
+
metrics.push({ name, type: entry.type, help: entry.help, unit: entry.unit || undefined });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return metrics;
|
|
82
|
+
}
|
|
83
|
+
async queryMetrics(params) {
|
|
84
|
+
const promql = this.buildQuery(params.service, params.metric);
|
|
85
|
+
const { start, end, step } = this.parseTimeRange(params.duration, params.step);
|
|
86
|
+
const data = await this.apiGet(`/api/v1/query_range?query=${encodeURIComponent(promql)}&start=${start}&end=${end}&step=${step}`);
|
|
87
|
+
const values = [];
|
|
88
|
+
const rawValues = [];
|
|
89
|
+
const resultData = data?.data?.result?.[0]?.values || [];
|
|
90
|
+
for (const [ts, val] of resultData) {
|
|
91
|
+
const numVal = parseFloat(val);
|
|
92
|
+
if (!isNaN(numVal)) {
|
|
93
|
+
values.push({ timestamp: new Date(ts * 1000).toISOString(), value: numVal });
|
|
94
|
+
rawValues.push(numVal);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
source: this.name,
|
|
99
|
+
service: params.service,
|
|
100
|
+
metric: params.metric,
|
|
101
|
+
unit: this.getUnit(params.metric),
|
|
102
|
+
values,
|
|
103
|
+
summary: this.computeSummary(rawValues),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// --- Private helpers ---
|
|
107
|
+
buildQuery(service, metric) {
|
|
108
|
+
const def = this.metrics.find((m) => m.name === metric);
|
|
109
|
+
if (def) {
|
|
110
|
+
return def.query.replace(/\{\{service\}\}/g, service);
|
|
111
|
+
}
|
|
112
|
+
return `${metric}{job="${service}"}`;
|
|
113
|
+
}
|
|
114
|
+
getUnit(metric) {
|
|
115
|
+
const def = this.metrics.find((m) => m.name === metric);
|
|
116
|
+
if (def)
|
|
117
|
+
return def.unit;
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
parseTimeRange(duration, step) {
|
|
121
|
+
const now = Math.floor(Date.now() / 1000);
|
|
122
|
+
const match = duration.match(/^(\d+)([mhd])$/);
|
|
123
|
+
if (!match)
|
|
124
|
+
throw new Error(`Invalid duration: ${duration}`);
|
|
125
|
+
const value = parseInt(match[1]);
|
|
126
|
+
const unit = match[2];
|
|
127
|
+
const seconds = unit === "m" ? value * 60 : unit === "h" ? value * 3600 : value * 86400;
|
|
128
|
+
const autoStep = Math.max(Math.floor(seconds / 100), 5);
|
|
129
|
+
return { start: now - seconds, end: now, step: step || `${autoStep}s` };
|
|
130
|
+
}
|
|
131
|
+
computeSummary(values) {
|
|
132
|
+
if (values.length === 0) {
|
|
133
|
+
return { current: 0, average: 0, min: 0, max: 0, trend: "stable" };
|
|
134
|
+
}
|
|
135
|
+
const current = values[values.length - 1];
|
|
136
|
+
const average = values.reduce((a, b) => a + b, 0) / values.length;
|
|
137
|
+
const min = Math.min(...values);
|
|
138
|
+
const max = Math.max(...values);
|
|
139
|
+
return { current, average, min, max, trend: this.computeTrend(values) };
|
|
140
|
+
}
|
|
141
|
+
computeTrend(values) {
|
|
142
|
+
if (values.length < 4)
|
|
143
|
+
return "stable";
|
|
144
|
+
const mid = Math.floor(values.length / 2);
|
|
145
|
+
const avgFirst = values.slice(0, mid).reduce((a, b) => a + b, 0) / mid;
|
|
146
|
+
const avgSecond = values.slice(mid).reduce((a, b) => a + b, 0) / (values.length - mid);
|
|
147
|
+
const changePercent = ((avgSecond - avgFirst) / (avgFirst || 1)) * 100;
|
|
148
|
+
if (changePercent > 10)
|
|
149
|
+
return "rising";
|
|
150
|
+
if (changePercent < -10)
|
|
151
|
+
return "falling";
|
|
152
|
+
return "stable";
|
|
153
|
+
}
|
|
154
|
+
buildAuthHeaders() {
|
|
155
|
+
if (!this.auth || this.auth.type === "none")
|
|
156
|
+
return {};
|
|
157
|
+
if (this.auth.type === "bearer" && this.auth.token) {
|
|
158
|
+
return { Authorization: `Bearer ${this.auth.token}` };
|
|
159
|
+
}
|
|
160
|
+
if (this.auth.type === "basic" && this.auth.username) {
|
|
161
|
+
const encoded = Buffer.from(`${this.auth.username}:${this.auth.password || ""}`).toString("base64");
|
|
162
|
+
return { Authorization: `Basic ${encoded}` };
|
|
163
|
+
}
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
fetchOptions() {
|
|
167
|
+
const opts = { headers: this.buildAuthHeaders() };
|
|
168
|
+
if (this.tlsAgent) {
|
|
169
|
+
// @ts-expect-error Node.js extension for native fetch
|
|
170
|
+
opts.dispatcher = this.tlsAgent;
|
|
171
|
+
}
|
|
172
|
+
return opts;
|
|
173
|
+
}
|
|
174
|
+
async apiGet(path, timeoutMs = 10000) {
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
179
|
+
...this.fetchOptions(),
|
|
180
|
+
signal: controller.signal,
|
|
181
|
+
});
|
|
182
|
+
if (!res.ok)
|
|
183
|
+
throw new Error(`Prometheus API error: ${res.status} ${res.statusText}`);
|
|
184
|
+
return res.json();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
188
|
+
throw new Error(`Prometheus query timed out after ${timeoutMs}ms`);
|
|
189
|
+
}
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { PrometheusConnector } from "./prometheus.js";
|
|
4
|
+
// Access private methods via prototype for testing pure logic
|
|
5
|
+
const proto = PrometheusConnector.prototype;
|
|
6
|
+
describe("PrometheusConnector", () => {
|
|
7
|
+
describe("parseTimeRange", () => {
|
|
8
|
+
it("parses minutes", () => {
|
|
9
|
+
const { start, end, step } = proto.parseTimeRange("5m");
|
|
10
|
+
assert.ok(end - start >= 299 && end - start <= 301); // ~300s
|
|
11
|
+
assert.equal(step, "5s"); // max(floor(300/100), 5) = 5
|
|
12
|
+
});
|
|
13
|
+
it("parses hours", () => {
|
|
14
|
+
const { start, end, step } = proto.parseTimeRange("1h");
|
|
15
|
+
assert.ok(end - start >= 3599 && end - start <= 3601); // ~3600s
|
|
16
|
+
assert.equal(step, "36s"); // floor(3600/100) = 36
|
|
17
|
+
});
|
|
18
|
+
it("parses days", () => {
|
|
19
|
+
const { start, end, step } = proto.parseTimeRange("7d");
|
|
20
|
+
assert.ok(end - start >= 604799 && end - start <= 604801);
|
|
21
|
+
assert.equal(step, "6048s"); // floor(604800/100)
|
|
22
|
+
});
|
|
23
|
+
it("uses custom step when provided", () => {
|
|
24
|
+
const { step } = proto.parseTimeRange("1h", "15s");
|
|
25
|
+
assert.equal(step, "15s");
|
|
26
|
+
});
|
|
27
|
+
it("throws on invalid duration", () => {
|
|
28
|
+
assert.throws(() => proto.parseTimeRange("invalid"));
|
|
29
|
+
assert.throws(() => proto.parseTimeRange("5s"));
|
|
30
|
+
assert.throws(() => proto.parseTimeRange(""));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("computeTrend", () => {
|
|
34
|
+
it("returns stable for fewer than 4 values", () => {
|
|
35
|
+
assert.equal(proto.computeTrend([1, 2, 3]), "stable");
|
|
36
|
+
assert.equal(proto.computeTrend([1]), "stable");
|
|
37
|
+
assert.equal(proto.computeTrend([]), "stable");
|
|
38
|
+
});
|
|
39
|
+
it("detects rising trend", () => {
|
|
40
|
+
assert.equal(proto.computeTrend([1, 1, 10, 10]), "rising");
|
|
41
|
+
assert.equal(proto.computeTrend([1, 2, 5, 8, 10, 15]), "rising");
|
|
42
|
+
});
|
|
43
|
+
it("detects falling trend", () => {
|
|
44
|
+
assert.equal(proto.computeTrend([10, 10, 1, 1]), "falling");
|
|
45
|
+
assert.equal(proto.computeTrend([20, 18, 5, 3, 2, 1]), "falling");
|
|
46
|
+
});
|
|
47
|
+
it("returns stable for flat data", () => {
|
|
48
|
+
assert.equal(proto.computeTrend([5, 5, 5, 5]), "stable");
|
|
49
|
+
assert.equal(proto.computeTrend([10, 10.5, 10, 10.5]), "stable");
|
|
50
|
+
});
|
|
51
|
+
it("returns stable for small changes within 10%", () => {
|
|
52
|
+
assert.equal(proto.computeTrend([100, 100, 105, 105]), "stable");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("computeSummary", () => {
|
|
56
|
+
it("returns zeros for empty array", () => {
|
|
57
|
+
const s = proto.computeSummary([]);
|
|
58
|
+
assert.equal(s.current, 0);
|
|
59
|
+
assert.equal(s.average, 0);
|
|
60
|
+
assert.equal(s.min, 0);
|
|
61
|
+
assert.equal(s.max, 0);
|
|
62
|
+
assert.equal(s.trend, "stable");
|
|
63
|
+
});
|
|
64
|
+
it("computes correct summary for values", () => {
|
|
65
|
+
const s = proto.computeSummary([10, 20, 30, 40]);
|
|
66
|
+
assert.equal(s.current, 40);
|
|
67
|
+
assert.equal(s.average, 25);
|
|
68
|
+
assert.equal(s.min, 10);
|
|
69
|
+
assert.equal(s.max, 40);
|
|
70
|
+
});
|
|
71
|
+
it("handles single value", () => {
|
|
72
|
+
const s = proto.computeSummary([42]);
|
|
73
|
+
assert.equal(s.current, 42);
|
|
74
|
+
assert.equal(s.average, 42);
|
|
75
|
+
assert.equal(s.min, 42);
|
|
76
|
+
assert.equal(s.max, 42);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("buildQuery", () => {
|
|
80
|
+
it("replaces {{service}} placeholder in known metrics", async () => {
|
|
81
|
+
const connector = new PrometheusConnector();
|
|
82
|
+
await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
|
|
83
|
+
const query = proto.buildQuery.call(connector, "payment-service", "cpu");
|
|
84
|
+
assert.ok(query.includes("payment-service"));
|
|
85
|
+
assert.ok(!query.includes("{{service}}"));
|
|
86
|
+
});
|
|
87
|
+
it("falls back to generic query for unknown metrics", async () => {
|
|
88
|
+
const connector = new PrometheusConnector();
|
|
89
|
+
await connector.connect({ name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true });
|
|
90
|
+
const query = proto.buildQuery.call(connector, "my-svc", "unknown_metric");
|
|
91
|
+
assert.equal(query, 'unknown_metric{job="my-svc"}');
|
|
92
|
+
});
|
|
93
|
+
it("uses custom metrics from source config", async () => {
|
|
94
|
+
const connector = new PrometheusConnector();
|
|
95
|
+
await connector.connect({
|
|
96
|
+
name: "test", type: "prometheus", url: "http://localhost:9090", enabled: true,
|
|
97
|
+
metrics: [{ name: "custom", query: 'my_custom_metric{svc="{{service}}"}', unit: "ops", description: "Custom" }],
|
|
98
|
+
});
|
|
99
|
+
const query = proto.buildQuery.call(connector, "api", "custom");
|
|
100
|
+
assert.equal(query, 'my_custom_metric{svc="api"}');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ObservabilityConnector } from "./interface.js";
|
|
2
|
+
import type { Config, ConnectorHealth, SignalType, SourceConfig } from "../types.js";
|
|
3
|
+
export declare function getSupportedTypes(): string[];
|
|
4
|
+
export declare class ConnectorRegistry {
|
|
5
|
+
private connectors;
|
|
6
|
+
private sourceConfigs;
|
|
7
|
+
initialize(config: Config): Promise<void>;
|
|
8
|
+
private connectSource;
|
|
9
|
+
addSource(source: SourceConfig): Promise<void>;
|
|
10
|
+
removeSource(name: string): Promise<void>;
|
|
11
|
+
updateSource(name: string, source: SourceConfig): Promise<void>;
|
|
12
|
+
testConnection(source: SourceConfig): Promise<ConnectorHealth>;
|
|
13
|
+
getSourceConfigs(): SourceConfig[];
|
|
14
|
+
getAll(): ObservabilityConnector[];
|
|
15
|
+
getByName(name: string): ObservabilityConnector | undefined;
|
|
16
|
+
getBySignal(signal: SignalType): ObservabilityConnector[];
|
|
17
|
+
healthCheckAll(): Promise<Record<string, ConnectorHealth>>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { PrometheusConnector } from "./prometheus.js";
|
|
2
|
+
import { LokiConnector } from "./loki.js";
|
|
3
|
+
const connectorFactories = {
|
|
4
|
+
prometheus: () => new PrometheusConnector(),
|
|
5
|
+
loki: () => new LokiConnector(),
|
|
6
|
+
};
|
|
7
|
+
export function getSupportedTypes() {
|
|
8
|
+
return Object.keys(connectorFactories);
|
|
9
|
+
}
|
|
10
|
+
export class ConnectorRegistry {
|
|
11
|
+
connectors = new Map();
|
|
12
|
+
sourceConfigs = new Map();
|
|
13
|
+
async initialize(config) {
|
|
14
|
+
for (const source of config.sources) {
|
|
15
|
+
this.sourceConfigs.set(source.name, source);
|
|
16
|
+
if (!source.enabled)
|
|
17
|
+
continue;
|
|
18
|
+
await this.connectSource(source);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async connectSource(source) {
|
|
22
|
+
const factory = connectorFactories[source.type];
|
|
23
|
+
if (!factory) {
|
|
24
|
+
console.warn(`Unknown connector type: ${source.type}, skipping ${source.name}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const connector = factory();
|
|
28
|
+
try {
|
|
29
|
+
await connector.connect(source);
|
|
30
|
+
this.connectors.set(source.name, connector);
|
|
31
|
+
console.log(`Connector "${source.name}" (${source.type}) connected`);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error(`Failed to connect "${source.name}":`, err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async addSource(source) {
|
|
38
|
+
this.sourceConfigs.set(source.name, source);
|
|
39
|
+
if (source.enabled) {
|
|
40
|
+
await this.connectSource(source);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async removeSource(name) {
|
|
44
|
+
const connector = this.connectors.get(name);
|
|
45
|
+
if (connector) {
|
|
46
|
+
await connector.disconnect();
|
|
47
|
+
this.connectors.delete(name);
|
|
48
|
+
}
|
|
49
|
+
this.sourceConfigs.delete(name);
|
|
50
|
+
}
|
|
51
|
+
async updateSource(name, source) {
|
|
52
|
+
await this.removeSource(name);
|
|
53
|
+
await this.addSource(source);
|
|
54
|
+
}
|
|
55
|
+
async testConnection(source) {
|
|
56
|
+
const factory = connectorFactories[source.type];
|
|
57
|
+
if (!factory) {
|
|
58
|
+
return { status: "down", latencyMs: 0, message: `Unknown type: ${source.type}` };
|
|
59
|
+
}
|
|
60
|
+
const connector = factory();
|
|
61
|
+
try {
|
|
62
|
+
await connector.connect(source);
|
|
63
|
+
const health = await connector.healthCheck();
|
|
64
|
+
await connector.disconnect();
|
|
65
|
+
return health;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return { status: "down", latencyMs: 0, message: String(err) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
getSourceConfigs() {
|
|
72
|
+
return Array.from(this.sourceConfigs.values());
|
|
73
|
+
}
|
|
74
|
+
getAll() {
|
|
75
|
+
return Array.from(this.connectors.values());
|
|
76
|
+
}
|
|
77
|
+
getByName(name) {
|
|
78
|
+
return this.connectors.get(name);
|
|
79
|
+
}
|
|
80
|
+
getBySignal(signal) {
|
|
81
|
+
return this.getAll().filter((c) => c.signalType === signal);
|
|
82
|
+
}
|
|
83
|
+
async healthCheckAll() {
|
|
84
|
+
const results = {};
|
|
85
|
+
for (const [name, connector] of this.connectors) {
|
|
86
|
+
results[name] = await connector.healthCheck();
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { getSupportedTypes, ConnectorRegistry } from "./registry.js";
|
|
4
|
+
import { DEFAULT_SETTINGS, DEFAULT_HEALTH_THRESHOLDS } from "../config/loader.js";
|
|
5
|
+
function makeConfig(sources = []) {
|
|
6
|
+
return { sources, settings: DEFAULT_SETTINGS, healthThresholds: DEFAULT_HEALTH_THRESHOLDS };
|
|
7
|
+
}
|
|
8
|
+
describe("getSupportedTypes", () => {
|
|
9
|
+
it("returns prometheus and loki", () => {
|
|
10
|
+
const types = getSupportedTypes();
|
|
11
|
+
assert.ok(types.includes("prometheus"));
|
|
12
|
+
assert.ok(types.includes("loki"));
|
|
13
|
+
assert.equal(types.length, 2);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("ConnectorRegistry", () => {
|
|
17
|
+
describe("initialize", () => {
|
|
18
|
+
it("stores source configs even when disabled", async () => {
|
|
19
|
+
const reg = new ConnectorRegistry();
|
|
20
|
+
await reg.initialize(makeConfig([
|
|
21
|
+
{ name: "prom1", type: "prometheus", url: "http://invalid:9090", enabled: false },
|
|
22
|
+
]));
|
|
23
|
+
const configs = reg.getSourceConfigs();
|
|
24
|
+
assert.equal(configs.length, 1);
|
|
25
|
+
assert.equal(configs[0].name, "prom1");
|
|
26
|
+
// Not connected since disabled
|
|
27
|
+
assert.equal(reg.getAll().length, 0);
|
|
28
|
+
});
|
|
29
|
+
it("skips unknown connector types gracefully", async () => {
|
|
30
|
+
const reg = new ConnectorRegistry();
|
|
31
|
+
await reg.initialize(makeConfig([
|
|
32
|
+
{ name: "unknown1", type: "influxdb", url: "http://localhost:8086", enabled: true },
|
|
33
|
+
]));
|
|
34
|
+
assert.equal(reg.getSourceConfigs().length, 1);
|
|
35
|
+
assert.equal(reg.getAll().length, 0); // not connected
|
|
36
|
+
});
|
|
37
|
+
it("handles empty sources", async () => {
|
|
38
|
+
const reg = new ConnectorRegistry();
|
|
39
|
+
await reg.initialize(makeConfig([]));
|
|
40
|
+
assert.equal(reg.getSourceConfigs().length, 0);
|
|
41
|
+
assert.equal(reg.getAll().length, 0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("getByName", () => {
|
|
45
|
+
it("returns undefined for non-existent source", () => {
|
|
46
|
+
const reg = new ConnectorRegistry();
|
|
47
|
+
assert.equal(reg.getByName("nonexistent"), undefined);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("getBySignal", () => {
|
|
51
|
+
it("returns empty array when no connectors", () => {
|
|
52
|
+
const reg = new ConnectorRegistry();
|
|
53
|
+
assert.deepEqual(reg.getBySignal("metrics"), []);
|
|
54
|
+
assert.deepEqual(reg.getBySignal("logs"), []);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("addSource and removeSource", () => {
|
|
58
|
+
it("adds disabled source without connecting", async () => {
|
|
59
|
+
const reg = new ConnectorRegistry();
|
|
60
|
+
await reg.addSource({ name: "prom-disabled", type: "prometheus", url: "http://invalid:9090", enabled: false });
|
|
61
|
+
assert.equal(reg.getSourceConfigs().length, 1);
|
|
62
|
+
assert.equal(reg.getAll().length, 0);
|
|
63
|
+
});
|
|
64
|
+
it("removes source config and connector", async () => {
|
|
65
|
+
const reg = new ConnectorRegistry();
|
|
66
|
+
await reg.addSource({ name: "test-src", type: "prometheus", url: "http://invalid:9090", enabled: false });
|
|
67
|
+
assert.equal(reg.getSourceConfigs().length, 1);
|
|
68
|
+
await reg.removeSource("test-src");
|
|
69
|
+
assert.equal(reg.getSourceConfigs().length, 0);
|
|
70
|
+
});
|
|
71
|
+
it("removeSource is safe for non-existent name", async () => {
|
|
72
|
+
const reg = new ConnectorRegistry();
|
|
73
|
+
await reg.removeSource("does-not-exist"); // should not throw
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("testConnection", () => {
|
|
77
|
+
it("returns error for unknown connector type", async () => {
|
|
78
|
+
const reg = new ConnectorRegistry();
|
|
79
|
+
const result = await reg.testConnection({
|
|
80
|
+
name: "test", type: "unknown_type", url: "http://localhost", enabled: true,
|
|
81
|
+
});
|
|
82
|
+
assert.equal(result.status, "down");
|
|
83
|
+
assert.ok(result.message?.includes("Unknown type"));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("healthCheckAll", () => {
|
|
87
|
+
it("returns empty object when no connectors", async () => {
|
|
88
|
+
const reg = new ConnectorRegistry();
|
|
89
|
+
const results = await reg.healthCheckAll();
|
|
90
|
+
assert.deepEqual(results, {});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Agent } from "node:https";
|
|
2
|
+
import type { SourceConfig } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build an https.Agent from a SourceConfig's TLS settings.
|
|
5
|
+
* Returns undefined if no custom TLS config is needed (plain HTTP or default HTTPS).
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildTlsAgent(config: SourceConfig): Agent | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Agent } from "node:https";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Build an https.Agent from a SourceConfig's TLS settings.
|
|
5
|
+
* Returns undefined if no custom TLS config is needed (plain HTTP or default HTTPS).
|
|
6
|
+
*/
|
|
7
|
+
export function buildTlsAgent(config) {
|
|
8
|
+
const tls = config.tls;
|
|
9
|
+
const skipVerify = tls?.skipVerify || config.tlsSkipVerify;
|
|
10
|
+
// No TLS customization needed
|
|
11
|
+
if (!skipVerify && !tls?.caCert && !tls?.clientCert)
|
|
12
|
+
return undefined;
|
|
13
|
+
const opts = {};
|
|
14
|
+
if (skipVerify) {
|
|
15
|
+
opts.rejectUnauthorized = false;
|
|
16
|
+
}
|
|
17
|
+
if (tls?.caCert) {
|
|
18
|
+
opts.ca = readFileSync(tls.caCert);
|
|
19
|
+
}
|
|
20
|
+
if (tls?.clientCert && tls?.clientKey) {
|
|
21
|
+
opts.cert = readFileSync(tls.clientCert);
|
|
22
|
+
opts.key = readFileSync(tls.clientKey);
|
|
23
|
+
}
|
|
24
|
+
return new Agent(opts);
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Agent } from "node:https";
|
|
4
|
+
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { buildTlsAgent } from "./tls.js";
|
|
8
|
+
const TMP_DIR = join(tmpdir(), "tls-test-" + Date.now());
|
|
9
|
+
function makeConfig(overrides = {}) {
|
|
10
|
+
return { name: "test", type: "prometheus", url: "https://localhost:9090", enabled: true, ...overrides };
|
|
11
|
+
}
|
|
12
|
+
describe("buildTlsAgent", () => {
|
|
13
|
+
it("returns undefined when no TLS config is set", () => {
|
|
14
|
+
const agent = buildTlsAgent(makeConfig());
|
|
15
|
+
assert.equal(agent, undefined);
|
|
16
|
+
});
|
|
17
|
+
it("returns undefined for plain HTTP URLs without TLS config", () => {
|
|
18
|
+
const agent = buildTlsAgent(makeConfig({ url: "http://localhost:9090" }));
|
|
19
|
+
assert.equal(agent, undefined);
|
|
20
|
+
});
|
|
21
|
+
it("returns Agent with rejectUnauthorized=false when skipVerify is true", () => {
|
|
22
|
+
const agent = buildTlsAgent(makeConfig({ tls: { skipVerify: true } }));
|
|
23
|
+
assert.ok(agent instanceof Agent);
|
|
24
|
+
assert.equal(agent.options.rejectUnauthorized, false);
|
|
25
|
+
});
|
|
26
|
+
it("supports legacy tlsSkipVerify field", () => {
|
|
27
|
+
const agent = buildTlsAgent(makeConfig({ tlsSkipVerify: true }));
|
|
28
|
+
assert.ok(agent instanceof Agent);
|
|
29
|
+
assert.equal(agent.options.rejectUnauthorized, false);
|
|
30
|
+
});
|
|
31
|
+
it("prefers tls.skipVerify over legacy tlsSkipVerify", () => {
|
|
32
|
+
const agent = buildTlsAgent(makeConfig({
|
|
33
|
+
tlsSkipVerify: false,
|
|
34
|
+
tls: { skipVerify: true },
|
|
35
|
+
}));
|
|
36
|
+
assert.ok(agent instanceof Agent);
|
|
37
|
+
assert.equal(agent.options.rejectUnauthorized, false);
|
|
38
|
+
});
|
|
39
|
+
describe("with certificate files", () => {
|
|
40
|
+
before(() => {
|
|
41
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
42
|
+
writeFileSync(join(TMP_DIR, "ca.pem"), "-----BEGIN CERTIFICATE-----\nfake-ca\n-----END CERTIFICATE-----\n");
|
|
43
|
+
writeFileSync(join(TMP_DIR, "client.pem"), "-----BEGIN CERTIFICATE-----\nfake-client\n-----END CERTIFICATE-----\n");
|
|
44
|
+
writeFileSync(join(TMP_DIR, "client-key.pem"), "-----BEGIN PRIVATE KEY-----\nfake-key\n-----END PRIVATE KEY-----\n");
|
|
45
|
+
});
|
|
46
|
+
after(() => {
|
|
47
|
+
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
it("loads custom CA certificate", () => {
|
|
50
|
+
const agent = buildTlsAgent(makeConfig({
|
|
51
|
+
tls: { caCert: join(TMP_DIR, "ca.pem") },
|
|
52
|
+
}));
|
|
53
|
+
assert.ok(agent instanceof Agent);
|
|
54
|
+
assert.ok(agent.options.ca);
|
|
55
|
+
});
|
|
56
|
+
it("loads client cert and key for mTLS", () => {
|
|
57
|
+
const agent = buildTlsAgent(makeConfig({
|
|
58
|
+
tls: {
|
|
59
|
+
clientCert: join(TMP_DIR, "client.pem"),
|
|
60
|
+
clientKey: join(TMP_DIR, "client-key.pem"),
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
assert.ok(agent instanceof Agent);
|
|
64
|
+
assert.ok(agent.options.cert);
|
|
65
|
+
assert.ok(agent.options.key);
|
|
66
|
+
});
|
|
67
|
+
it("combines CA + mTLS + skipVerify", () => {
|
|
68
|
+
const agent = buildTlsAgent(makeConfig({
|
|
69
|
+
tls: {
|
|
70
|
+
skipVerify: true,
|
|
71
|
+
caCert: join(TMP_DIR, "ca.pem"),
|
|
72
|
+
clientCert: join(TMP_DIR, "client.pem"),
|
|
73
|
+
clientKey: join(TMP_DIR, "client-key.pem"),
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
assert.ok(agent instanceof Agent);
|
|
77
|
+
assert.equal(agent.options.rejectUnauthorized, false);
|
|
78
|
+
assert.ok(agent.options.ca);
|
|
79
|
+
assert.ok(agent.options.cert);
|
|
80
|
+
assert.ok(agent.options.key);
|
|
81
|
+
});
|
|
82
|
+
it("ignores clientCert without clientKey (no cert/key set on agent)", () => {
|
|
83
|
+
const agent = buildTlsAgent(makeConfig({
|
|
84
|
+
tls: { clientCert: join(TMP_DIR, "client.pem") },
|
|
85
|
+
}));
|
|
86
|
+
// Agent is created (clientCert triggers it) but cert/key are not set without both
|
|
87
|
+
assert.ok(agent instanceof Agent);
|
|
88
|
+
assert.equal(agent.options.cert, undefined);
|
|
89
|
+
assert.equal(agent.options.key, undefined);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
it("throws when CA cert file does not exist", () => {
|
|
93
|
+
assert.throws(() => {
|
|
94
|
+
buildTlsAgent(makeConfig({
|
|
95
|
+
tls: { caCert: "/nonexistent/ca.pem" },
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
package/dist/index.d.ts
ADDED