@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,163 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
// We test the helper functions by importing the module fresh with different env vars.
|
|
7
|
+
// Since the config path is resolved at import time, we use dynamic imports.
|
|
8
|
+
const TMP_DIR = join(tmpdir(), "observability-mcp-test-" + Date.now());
|
|
9
|
+
describe("config/loader", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mkdirSync(TMP_DIR, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(TMP_DIR, { recursive: true, force: true });
|
|
15
|
+
delete process.env.CONFIG_PATH;
|
|
16
|
+
delete process.env.PROMETHEUS_URL;
|
|
17
|
+
delete process.env.LOKI_URL;
|
|
18
|
+
});
|
|
19
|
+
describe("loadConfig with CONFIG_PATH", () => {
|
|
20
|
+
it("loads sources from YAML file", async () => {
|
|
21
|
+
const configPath = join(TMP_DIR, "sources.yaml");
|
|
22
|
+
writeFileSync(configPath, `
|
|
23
|
+
sources:
|
|
24
|
+
- name: prom1
|
|
25
|
+
type: prometheus
|
|
26
|
+
url: http://localhost:9090
|
|
27
|
+
enabled: true
|
|
28
|
+
`);
|
|
29
|
+
process.env.CONFIG_PATH = configPath;
|
|
30
|
+
// Dynamic import to pick up new env
|
|
31
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
32
|
+
// loadConfig is already called at module level for CONFIG_PATH, but we call it again
|
|
33
|
+
const config = mod.loadConfig();
|
|
34
|
+
assert.equal(config.sources.length, 1);
|
|
35
|
+
assert.equal(config.sources[0].name, "prom1");
|
|
36
|
+
assert.equal(config.sources[0].url, "http://localhost:9090");
|
|
37
|
+
});
|
|
38
|
+
it("falls back to env vars when config file does not exist", async () => {
|
|
39
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
40
|
+
process.env.PROMETHEUS_URL = "http://p1:9090";
|
|
41
|
+
process.env.LOKI_URL = "http://l1:3100";
|
|
42
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
43
|
+
const config = mod.loadConfig();
|
|
44
|
+
assert.equal(config.sources.length, 2);
|
|
45
|
+
assert.equal(config.sources[0].name, "prometheus");
|
|
46
|
+
assert.equal(config.sources[0].url, "http://p1:9090");
|
|
47
|
+
assert.equal(config.sources[1].name, "loki");
|
|
48
|
+
assert.equal(config.sources[1].url, "http://l1:3100");
|
|
49
|
+
});
|
|
50
|
+
it("returns empty sources when no config and no env vars", async () => {
|
|
51
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
52
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
53
|
+
const config = mod.loadConfig();
|
|
54
|
+
assert.equal(config.sources.length, 0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("env var parsing - comma-separated URLs", () => {
|
|
58
|
+
it("creates multiple prometheus sources from comma-separated URLs", async () => {
|
|
59
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
60
|
+
process.env.PROMETHEUS_URL = "http://p1:9090,http://p2:9090,http://p3:9090";
|
|
61
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
62
|
+
const config = mod.loadConfig();
|
|
63
|
+
assert.equal(config.sources.length, 3);
|
|
64
|
+
assert.equal(config.sources[0].name, "prometheus-1");
|
|
65
|
+
assert.equal(config.sources[0].url, "http://p1:9090");
|
|
66
|
+
assert.equal(config.sources[1].name, "prometheus-2");
|
|
67
|
+
assert.equal(config.sources[1].url, "http://p2:9090");
|
|
68
|
+
assert.equal(config.sources[2].name, "prometheus-3");
|
|
69
|
+
assert.equal(config.sources[2].url, "http://p3:9090");
|
|
70
|
+
});
|
|
71
|
+
it("uses plain name for single URL (no suffix)", async () => {
|
|
72
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
73
|
+
process.env.PROMETHEUS_URL = "http://p1:9090";
|
|
74
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
75
|
+
const config = mod.loadConfig();
|
|
76
|
+
assert.equal(config.sources.length, 1);
|
|
77
|
+
assert.equal(config.sources[0].name, "prometheus");
|
|
78
|
+
});
|
|
79
|
+
it("trims whitespace from URLs", async () => {
|
|
80
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
81
|
+
process.env.LOKI_URL = " http://l1:3100 , http://l2:3100 ";
|
|
82
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
83
|
+
const config = mod.loadConfig();
|
|
84
|
+
assert.equal(config.sources.length, 2);
|
|
85
|
+
assert.equal(config.sources[0].url, "http://l1:3100");
|
|
86
|
+
assert.equal(config.sources[1].url, "http://l2:3100");
|
|
87
|
+
});
|
|
88
|
+
it("combines prometheus and loki sources", async () => {
|
|
89
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
90
|
+
process.env.PROMETHEUS_URL = "http://p1:9090,http://p2:9090";
|
|
91
|
+
process.env.LOKI_URL = "http://l1:3100";
|
|
92
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
93
|
+
const config = mod.loadConfig();
|
|
94
|
+
assert.equal(config.sources.length, 3);
|
|
95
|
+
assert.equal(config.sources[0].name, "prometheus-1");
|
|
96
|
+
assert.equal(config.sources[1].name, "prometheus-2");
|
|
97
|
+
assert.equal(config.sources[2].name, "loki");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("saveConfig", () => {
|
|
101
|
+
it("creates directory and writes YAML file", async () => {
|
|
102
|
+
const configPath = join(TMP_DIR, "sub", "dir", "sources.yaml");
|
|
103
|
+
process.env.CONFIG_PATH = configPath;
|
|
104
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
105
|
+
const config = mod.loadConfig();
|
|
106
|
+
config.sources.push({ name: "test", type: "prometheus", url: "http://test:9090", enabled: true });
|
|
107
|
+
mod.saveConfig(config);
|
|
108
|
+
assert.ok(existsSync(configPath));
|
|
109
|
+
// Re-load and verify
|
|
110
|
+
const reloaded = mod.loadConfig();
|
|
111
|
+
assert.equal(reloaded.sources.length, 1);
|
|
112
|
+
assert.equal(reloaded.sources[0].name, "test");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("default settings", () => {
|
|
116
|
+
it("has correct defaults", async () => {
|
|
117
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
118
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
119
|
+
const config = mod.loadConfig();
|
|
120
|
+
assert.equal(config.settings.checkIntervalMs, 30000);
|
|
121
|
+
assert.equal(config.settings.defaultSensitivity, "medium");
|
|
122
|
+
});
|
|
123
|
+
it("has correct default health thresholds", async () => {
|
|
124
|
+
process.env.CONFIG_PATH = join(TMP_DIR, "nonexistent.yaml");
|
|
125
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
126
|
+
const config = mod.loadConfig();
|
|
127
|
+
assert.equal(config.healthThresholds.weights.errorRate, 0.35);
|
|
128
|
+
assert.equal(config.healthThresholds.cpu.crit, 95);
|
|
129
|
+
assert.equal(config.healthThresholds.statusBoundaries.healthy, 80);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe("config merging", () => {
|
|
133
|
+
it("merges partial settings with defaults", async () => {
|
|
134
|
+
const configPath = join(TMP_DIR, "partial.yaml");
|
|
135
|
+
writeFileSync(configPath, `
|
|
136
|
+
sources: []
|
|
137
|
+
settings:
|
|
138
|
+
checkIntervalMs: 60000
|
|
139
|
+
`);
|
|
140
|
+
process.env.CONFIG_PATH = configPath;
|
|
141
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
142
|
+
const config = mod.loadConfig();
|
|
143
|
+
assert.equal(config.settings.checkIntervalMs, 60000);
|
|
144
|
+
assert.equal(config.settings.defaultSensitivity, "medium"); // default preserved
|
|
145
|
+
});
|
|
146
|
+
it("deep-merges health thresholds", async () => {
|
|
147
|
+
const configPath = join(TMP_DIR, "thresholds.yaml");
|
|
148
|
+
writeFileSync(configPath, `
|
|
149
|
+
sources: []
|
|
150
|
+
healthThresholds:
|
|
151
|
+
cpu:
|
|
152
|
+
crit: 99
|
|
153
|
+
`);
|
|
154
|
+
process.env.CONFIG_PATH = configPath;
|
|
155
|
+
const mod = await import("./loader.js?" + Date.now());
|
|
156
|
+
const config = mod.loadConfig();
|
|
157
|
+
assert.equal(config.healthThresholds.cpu.crit, 99); // overridden
|
|
158
|
+
assert.equal(config.healthThresholds.cpu.good, 50); // default preserved
|
|
159
|
+
assert.equal(config.healthThresholds.cpu.warn, 80); // default preserved
|
|
160
|
+
assert.equal(config.healthThresholds.weights.errorRate, 0.35); // other sections preserved
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SignalType, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, LogQuery, LogResult, SourceConfig, MetricDefinition } from "../types.js";
|
|
2
|
+
export interface ObservabilityConnector {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly signalType: SignalType;
|
|
6
|
+
connect(config: SourceConfig): Promise<void>;
|
|
7
|
+
healthCheck(): Promise<ConnectorHealth>;
|
|
8
|
+
disconnect(): Promise<void>;
|
|
9
|
+
/** Returns the default metric definitions for this connector type */
|
|
10
|
+
getDefaultMetrics(): MetricDefinition[];
|
|
11
|
+
/** Returns the active metrics (user-configured or defaults) */
|
|
12
|
+
getMetrics(): MetricDefinition[];
|
|
13
|
+
listServices(): Promise<ServiceInfo[]>;
|
|
14
|
+
listAvailableMetrics?(service: string): Promise<MetricInfo[]>;
|
|
15
|
+
queryMetrics?(params: MetricQuery): Promise<MetricResult>;
|
|
16
|
+
queryLogs?(params: LogQuery): Promise<LogResult>;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ObservabilityConnector } from "./interface.js";
|
|
2
|
+
import type { SourceConfig, ConnectorHealth, ServiceInfo, MetricDefinition, LogQuery, LogResult, SignalType } from "../types.js";
|
|
3
|
+
export declare class LokiConnector implements ObservabilityConnector {
|
|
4
|
+
readonly type = "loki";
|
|
5
|
+
readonly signalType: SignalType;
|
|
6
|
+
name: string;
|
|
7
|
+
private baseUrl;
|
|
8
|
+
private auth?;
|
|
9
|
+
private tlsAgent?;
|
|
10
|
+
connect(config: SourceConfig): Promise<void>;
|
|
11
|
+
getDefaultMetrics(): MetricDefinition[];
|
|
12
|
+
getMetrics(): MetricDefinition[];
|
|
13
|
+
private fetchOptions;
|
|
14
|
+
healthCheck(): Promise<ConnectorHealth>;
|
|
15
|
+
disconnect(): Promise<void>;
|
|
16
|
+
listServices(): Promise<ServiceInfo[]>;
|
|
17
|
+
queryLogs(params: LogQuery): Promise<LogResult>;
|
|
18
|
+
private parseLine;
|
|
19
|
+
private extractTopPatterns;
|
|
20
|
+
private parseTimeRange;
|
|
21
|
+
private escapeLogQLValue;
|
|
22
|
+
private escapeLogQLRegex;
|
|
23
|
+
private buildAuthHeaders;
|
|
24
|
+
private apiGet;
|
|
25
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { buildTlsAgent } from "./tls.js";
|
|
2
|
+
export class LokiConnector {
|
|
3
|
+
type = "loki";
|
|
4
|
+
signalType = "logs";
|
|
5
|
+
name = "";
|
|
6
|
+
baseUrl = "";
|
|
7
|
+
auth;
|
|
8
|
+
tlsAgent;
|
|
9
|
+
async connect(config) {
|
|
10
|
+
this.name = config.name;
|
|
11
|
+
this.baseUrl = config.url.replace(/\/$/, "");
|
|
12
|
+
this.auth = config.auth;
|
|
13
|
+
this.tlsAgent = buildTlsAgent(config);
|
|
14
|
+
}
|
|
15
|
+
getDefaultMetrics() {
|
|
16
|
+
// Loki is a log backend — no metric definitions by default
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
getMetrics() {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
fetchOptions() {
|
|
23
|
+
const opts = { headers: this.buildAuthHeaders() };
|
|
24
|
+
if (this.tlsAgent) {
|
|
25
|
+
// @ts-expect-error Node.js extension for native fetch
|
|
26
|
+
opts.dispatcher = this.tlsAgent;
|
|
27
|
+
}
|
|
28
|
+
return opts;
|
|
29
|
+
}
|
|
30
|
+
async healthCheck() {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${this.baseUrl}/ready`, this.fetchOptions());
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
const isReady = res.ok && text.trim() === "ready";
|
|
36
|
+
return {
|
|
37
|
+
status: isReady ? "up" : "down",
|
|
38
|
+
latencyMs: Date.now() - start,
|
|
39
|
+
message: isReady ? "Loki is ready" : `HTTP ${res.status}: ${text}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return { status: "down", latencyMs: Date.now() - start, message: String(err) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async disconnect() { }
|
|
47
|
+
async listServices() {
|
|
48
|
+
try {
|
|
49
|
+
const data = await this.apiGet("/loki/api/v1/label/service/values");
|
|
50
|
+
return (data?.data || []).map((name) => ({
|
|
51
|
+
name,
|
|
52
|
+
source: this.name,
|
|
53
|
+
signalType: "logs",
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async queryLogs(params) {
|
|
61
|
+
const { start, end } = this.parseTimeRange(params.duration);
|
|
62
|
+
const limit = Math.min(Math.max(params.limit || 100, 1), 1000);
|
|
63
|
+
const service = this.escapeLogQLValue(params.service);
|
|
64
|
+
let logql = `{service="${service}"}`;
|
|
65
|
+
if (params.level) {
|
|
66
|
+
const level = this.escapeLogQLValue(params.level);
|
|
67
|
+
logql += ` | json | level="${level}"`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
logql += ` | json`;
|
|
71
|
+
}
|
|
72
|
+
if (params.query) {
|
|
73
|
+
const query = this.escapeLogQLRegex(params.query);
|
|
74
|
+
logql += ` |~ \`${query}\``;
|
|
75
|
+
}
|
|
76
|
+
const url = `/loki/api/v1/query_range?query=${encodeURIComponent(logql)}` +
|
|
77
|
+
`&start=${start}000000000&end=${end}000000000&limit=${limit}`;
|
|
78
|
+
const data = await this.apiGet(url);
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const stream of data?.data?.result || []) {
|
|
81
|
+
const labels = stream.stream;
|
|
82
|
+
for (const [ts, line] of stream.values) {
|
|
83
|
+
const parsed = this.parseLine(line);
|
|
84
|
+
entries.push({
|
|
85
|
+
timestamp: new Date(parseInt(ts) / 1_000_000).toISOString(),
|
|
86
|
+
level: parsed.level || labels.level || "unknown",
|
|
87
|
+
message: parsed.msg || line,
|
|
88
|
+
labels,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Sort newest first
|
|
93
|
+
entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
94
|
+
// Compute summary
|
|
95
|
+
const errorCount = entries.filter((e) => e.level === "error").length;
|
|
96
|
+
const warnCount = entries.filter((e) => e.level === "warn").length;
|
|
97
|
+
const topPatterns = this.extractTopPatterns(entries.filter((e) => e.level === "error"));
|
|
98
|
+
return {
|
|
99
|
+
source: this.name,
|
|
100
|
+
service: params.service,
|
|
101
|
+
entries,
|
|
102
|
+
summary: {
|
|
103
|
+
total: entries.length,
|
|
104
|
+
errorCount,
|
|
105
|
+
warnCount,
|
|
106
|
+
topPatterns,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// --- Private helpers ---
|
|
111
|
+
parseLine(line) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(line);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { msg: line };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
extractTopPatterns(errorEntries) {
|
|
120
|
+
const patterns = new Map();
|
|
121
|
+
for (const entry of errorEntries) {
|
|
122
|
+
// Use first 100 chars of message as pattern key
|
|
123
|
+
const key = entry.message.slice(0, 100);
|
|
124
|
+
patterns.set(key, (patterns.get(key) || 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
return Array.from(patterns.entries())
|
|
127
|
+
.sort((a, b) => b[1] - a[1])
|
|
128
|
+
.slice(0, 5)
|
|
129
|
+
.map(([pattern, count]) => `${pattern} (${count}x)`);
|
|
130
|
+
}
|
|
131
|
+
parseTimeRange(duration) {
|
|
132
|
+
const now = Math.floor(Date.now() / 1000);
|
|
133
|
+
const match = duration.match(/^(\d+)([mhd])$/);
|
|
134
|
+
if (!match)
|
|
135
|
+
throw new Error(`Invalid duration: ${duration}`);
|
|
136
|
+
const value = parseInt(match[1]);
|
|
137
|
+
const unit = match[2];
|
|
138
|
+
const seconds = unit === "m" ? value * 60 : unit === "h" ? value * 3600 : value * 86400;
|
|
139
|
+
return { start: now - seconds, end: now };
|
|
140
|
+
}
|
|
141
|
+
escapeLogQLValue(value) {
|
|
142
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
143
|
+
}
|
|
144
|
+
escapeLogQLRegex(value) {
|
|
145
|
+
// Escape backticks which would break the LogQL regex delimiter
|
|
146
|
+
return value.replace(/`/g, "\\`");
|
|
147
|
+
}
|
|
148
|
+
buildAuthHeaders() {
|
|
149
|
+
if (!this.auth || this.auth.type === "none")
|
|
150
|
+
return {};
|
|
151
|
+
if (this.auth.type === "bearer" && this.auth.token) {
|
|
152
|
+
return { Authorization: `Bearer ${this.auth.token}` };
|
|
153
|
+
}
|
|
154
|
+
if (this.auth.type === "basic" && this.auth.username) {
|
|
155
|
+
const encoded = Buffer.from(`${this.auth.username}:${this.auth.password || ""}`).toString("base64");
|
|
156
|
+
return { Authorization: `Basic ${encoded}` };
|
|
157
|
+
}
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
async apiGet(path, timeoutMs = 10000) {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
165
|
+
...this.fetchOptions(),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok)
|
|
169
|
+
throw new Error(`Loki API error: ${res.status} ${res.statusText}`);
|
|
170
|
+
return res.json();
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
174
|
+
throw new Error(`Loki query timed out after ${timeoutMs}ms`);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { LokiConnector } from "./loki.js";
|
|
4
|
+
const proto = LokiConnector.prototype;
|
|
5
|
+
describe("LokiConnector", () => {
|
|
6
|
+
describe("parseLine", () => {
|
|
7
|
+
it("parses valid JSON", () => {
|
|
8
|
+
const result = proto.parseLine('{"level":"error","msg":"timeout"}');
|
|
9
|
+
assert.equal(result.level, "error");
|
|
10
|
+
assert.equal(result.msg, "timeout");
|
|
11
|
+
});
|
|
12
|
+
it("returns msg wrapper for invalid JSON", () => {
|
|
13
|
+
const result = proto.parseLine("plain text log line");
|
|
14
|
+
assert.equal(result.msg, "plain text log line");
|
|
15
|
+
});
|
|
16
|
+
it("handles empty string", () => {
|
|
17
|
+
const result = proto.parseLine("");
|
|
18
|
+
assert.equal(result.msg, "");
|
|
19
|
+
});
|
|
20
|
+
it("parses complex JSON", () => {
|
|
21
|
+
const result = proto.parseLine('{"level":"info","msg":"ok","nested":{"key":"val"}}');
|
|
22
|
+
assert.equal(result.level, "info");
|
|
23
|
+
assert.deepEqual(result.nested, { key: "val" });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe("extractTopPatterns", () => {
|
|
27
|
+
it("returns empty for no entries", () => {
|
|
28
|
+
assert.deepEqual(proto.extractTopPatterns([]), []);
|
|
29
|
+
});
|
|
30
|
+
it("counts duplicate patterns", () => {
|
|
31
|
+
const entries = [
|
|
32
|
+
{ message: "connection timeout" },
|
|
33
|
+
{ message: "connection timeout" },
|
|
34
|
+
{ message: "connection timeout" },
|
|
35
|
+
{ message: "null pointer" },
|
|
36
|
+
];
|
|
37
|
+
const patterns = proto.extractTopPatterns(entries);
|
|
38
|
+
assert.equal(patterns.length, 2);
|
|
39
|
+
assert.ok(patterns[0].includes("connection timeout"));
|
|
40
|
+
assert.ok(patterns[0].includes("3x"));
|
|
41
|
+
assert.ok(patterns[1].includes("null pointer"));
|
|
42
|
+
assert.ok(patterns[1].includes("1x"));
|
|
43
|
+
});
|
|
44
|
+
it("limits to top 5 patterns", () => {
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (let i = 0; i < 10; i++) {
|
|
47
|
+
entries.push({ message: `error type ${i}` });
|
|
48
|
+
}
|
|
49
|
+
const patterns = proto.extractTopPatterns(entries);
|
|
50
|
+
assert.equal(patterns.length, 5);
|
|
51
|
+
});
|
|
52
|
+
it("sorts by count descending", () => {
|
|
53
|
+
const entries = [
|
|
54
|
+
{ message: "rare error" },
|
|
55
|
+
{ message: "common error" },
|
|
56
|
+
{ message: "common error" },
|
|
57
|
+
{ message: "common error" },
|
|
58
|
+
];
|
|
59
|
+
const patterns = proto.extractTopPatterns(entries);
|
|
60
|
+
assert.ok(patterns[0].includes("common error"));
|
|
61
|
+
assert.ok(patterns[0].includes("3x"));
|
|
62
|
+
});
|
|
63
|
+
it("truncates long messages to 100 chars for pattern key", () => {
|
|
64
|
+
const longMsg = "x".repeat(200);
|
|
65
|
+
const entries = [{ message: longMsg }, { message: longMsg }];
|
|
66
|
+
const patterns = proto.extractTopPatterns(entries);
|
|
67
|
+
assert.equal(patterns.length, 1);
|
|
68
|
+
assert.ok(patterns[0].includes("2x"));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("parseTimeRange", () => {
|
|
72
|
+
it("parses minutes", () => {
|
|
73
|
+
const { start, end } = proto.parseTimeRange("10m");
|
|
74
|
+
assert.ok(end - start >= 599 && end - start <= 601);
|
|
75
|
+
});
|
|
76
|
+
it("parses hours", () => {
|
|
77
|
+
const { start, end } = proto.parseTimeRange("2h");
|
|
78
|
+
assert.ok(end - start >= 7199 && end - start <= 7201);
|
|
79
|
+
});
|
|
80
|
+
it("parses days", () => {
|
|
81
|
+
const { start, end } = proto.parseTimeRange("1d");
|
|
82
|
+
assert.ok(end - start >= 86399 && end - start <= 86401);
|
|
83
|
+
});
|
|
84
|
+
it("throws on invalid duration", () => {
|
|
85
|
+
assert.throws(() => proto.parseTimeRange("invalid"));
|
|
86
|
+
assert.throws(() => proto.parseTimeRange("5s"));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("escapeLogQLValue", () => {
|
|
90
|
+
it("returns value unchanged when no escaping needed", () => {
|
|
91
|
+
assert.equal(proto.escapeLogQLValue("api-gateway"), "api-gateway");
|
|
92
|
+
});
|
|
93
|
+
it("escapes backslashes", () => {
|
|
94
|
+
assert.equal(proto.escapeLogQLValue("path\\to\\file"), "path\\\\to\\\\file");
|
|
95
|
+
});
|
|
96
|
+
it("escapes double quotes", () => {
|
|
97
|
+
assert.equal(proto.escapeLogQLValue('say "hello"'), 'say \\"hello\\"');
|
|
98
|
+
});
|
|
99
|
+
it("escapes both", () => {
|
|
100
|
+
assert.equal(proto.escapeLogQLValue('a\\b"c'), 'a\\\\b\\"c');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("escapeLogQLRegex", () => {
|
|
104
|
+
it("returns value unchanged when no backticks", () => {
|
|
105
|
+
assert.equal(proto.escapeLogQLRegex("error.*timeout"), "error.*timeout");
|
|
106
|
+
});
|
|
107
|
+
it("escapes backticks", () => {
|
|
108
|
+
assert.equal(proto.escapeLogQLRegex("error`test`"), "error\\`test\\`");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ObservabilityConnector } from "./interface.js";
|
|
2
|
+
import type { SourceConfig, ConnectorHealth, ServiceInfo, MetricInfo, MetricQuery, MetricResult, MetricDefinition, SignalType } from "../types.js";
|
|
3
|
+
export declare class PrometheusConnector implements ObservabilityConnector {
|
|
4
|
+
readonly type = "prometheus";
|
|
5
|
+
readonly signalType: SignalType;
|
|
6
|
+
name: string;
|
|
7
|
+
private baseUrl;
|
|
8
|
+
private auth?;
|
|
9
|
+
private tlsAgent?;
|
|
10
|
+
private metrics;
|
|
11
|
+
connect(config: SourceConfig): Promise<void>;
|
|
12
|
+
getDefaultMetrics(): MetricDefinition[];
|
|
13
|
+
getMetrics(): MetricDefinition[];
|
|
14
|
+
setMetrics(metrics: MetricDefinition[]): void;
|
|
15
|
+
healthCheck(): Promise<ConnectorHealth>;
|
|
16
|
+
disconnect(): Promise<void>;
|
|
17
|
+
listServices(): Promise<ServiceInfo[]>;
|
|
18
|
+
listAvailableMetrics(_service: string): Promise<MetricInfo[]>;
|
|
19
|
+
queryMetrics(params: MetricQuery): Promise<MetricResult>;
|
|
20
|
+
private buildQuery;
|
|
21
|
+
private getUnit;
|
|
22
|
+
private parseTimeRange;
|
|
23
|
+
private computeSummary;
|
|
24
|
+
private computeTrend;
|
|
25
|
+
private buildAuthHeaders;
|
|
26
|
+
private fetchOptions;
|
|
27
|
+
private apiGet;
|
|
28
|
+
}
|