@thotischner/observability-mcp 3.3.0 → 3.3.1

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.
@@ -7,12 +7,22 @@ export interface HealthInputs {
7
7
  logErrorRate: number;
8
8
  }
9
9
  export interface HealthResult {
10
- score: number;
11
- status: HealthStatus;
10
+ /** 0-100, or null when no signal had data (status "unknown"). */
11
+ score: number | null;
12
+ status: HealthStatus | "unknown";
12
13
  details: Record<string, {
13
14
  score: number;
14
15
  value: number;
15
16
  threshold: string;
16
17
  }>;
17
18
  }
18
- export declare function calculateHealthScore(inputs: HealthInputs, thresholds: HealthThresholds): HealthResult;
19
+ /** Which signal families actually returned data. Missing/false families are
20
+ * excluded from the weighted score and the remaining weights are
21
+ * renormalised — so a log-only service is judged on its logs, not on metric
22
+ * zeros coerced to "healthy" (issue #453). Omit for the back-compat
23
+ * all-signals-present behaviour. */
24
+ export interface SignalCoverage {
25
+ metrics?: boolean;
26
+ logs?: boolean;
27
+ }
28
+ export declare function calculateHealthScore(inputs: HealthInputs, thresholds: HealthThresholds, coverage?: SignalCoverage): HealthResult;
@@ -1,27 +1,43 @@
1
- export function calculateHealthScore(inputs, thresholds) {
1
+ export function calculateHealthScore(inputs, thresholds, coverage) {
2
2
  const w = thresholds.weights;
3
3
  const t = thresholds;
4
+ const hasMetrics = coverage?.metrics !== false; // default: present (back-compat)
5
+ const hasLogs = coverage?.logs !== false;
4
6
  const cpuScore = scoreFromThreshold(inputs.cpu, t.cpu.good, t.cpu.warn, t.cpu.crit);
5
7
  const errorRateScore = scoreFromThreshold(inputs.errorRate, t.errorRate.good, t.errorRate.warn, t.errorRate.crit);
6
8
  const latencyScore = scoreFromThreshold(inputs.latencyP99, t.latencyP99.good, t.latencyP99.warn, t.latencyP99.crit);
7
9
  const logErrorScore = scoreFromThreshold(inputs.logErrorRate, t.logErrors.good, t.logErrors.warn, t.logErrors.crit);
8
- const weightedScore = cpuScore * w.cpu +
9
- errorRateScore * w.errorRate +
10
- latencyScore * w.latency +
11
- logErrorScore * w.logErrors;
12
- const score = Math.round(Math.max(0, Math.min(100, weightedScore)));
10
+ // Only count the families that actually reported data; renormalise by the
11
+ // sum of their weights so a missing family is neither a free "100" nor a
12
+ // free "0". With full coverage the active weights sum to ~1 and this equals
13
+ // the previous behaviour.
14
+ let weighted = 0;
15
+ let activeWeight = 0;
16
+ if (hasMetrics) {
17
+ weighted += cpuScore * w.cpu + errorRateScore * w.errorRate + latencyScore * w.latency;
18
+ activeWeight += w.cpu + w.errorRate + w.latency;
19
+ }
20
+ if (hasLogs) {
21
+ weighted += logErrorScore * w.logErrors;
22
+ activeWeight += w.logErrors;
23
+ }
24
+ const details = {};
25
+ if (hasMetrics) {
26
+ details.cpu = { score: Math.round(cpuScore), value: inputs.cpu, threshold: `warn >${t.cpu.warn}%, crit >${t.cpu.crit}%` };
27
+ details.errorRate = { score: Math.round(errorRateScore), value: inputs.errorRate, threshold: `warn >${t.errorRate.warn}/s, crit >${t.errorRate.crit}/s` };
28
+ details.latencyP99 = { score: Math.round(latencyScore), value: inputs.latencyP99, threshold: `warn >${t.latencyP99.warn}s, crit >${t.latencyP99.crit}s` };
29
+ }
30
+ if (hasLogs) {
31
+ details.logErrors = { score: Math.round(logErrorScore), value: inputs.logErrorRate, threshold: `warn >${t.logErrors.warn}/min, crit >${t.logErrors.crit}/min` };
32
+ }
33
+ // No family reported data → honestly unknown, not a confident 100/healthy.
34
+ if (activeWeight === 0) {
35
+ return { score: null, status: "unknown", details };
36
+ }
37
+ const score = Math.round(Math.max(0, Math.min(100, weighted / activeWeight)));
13
38
  const status = score > t.statusBoundaries.healthy ? "healthy" :
14
39
  score > t.statusBoundaries.degraded ? "degraded" : "critical";
15
- return {
16
- score,
17
- status,
18
- details: {
19
- cpu: { score: Math.round(cpuScore), value: inputs.cpu, threshold: `warn >${t.cpu.warn}%, crit >${t.cpu.crit}%` },
20
- errorRate: { score: Math.round(errorRateScore), value: inputs.errorRate, threshold: `warn >${t.errorRate.warn}/s, crit >${t.errorRate.crit}/s` },
21
- latencyP99: { score: Math.round(latencyScore), value: inputs.latencyP99, threshold: `warn >${t.latencyP99.warn}s, crit >${t.latencyP99.crit}s` },
22
- logErrors: { score: Math.round(logErrorScore), value: inputs.logErrorRate, threshold: `warn >${t.logErrors.warn}/min, crit >${t.logErrors.crit}/min` },
23
- },
24
- };
40
+ return { score, status, details };
25
41
  }
26
42
  function scoreFromThreshold(value, good, warn, crit) {
27
43
  if (value <= good)
@@ -1,6 +1,11 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { calculateHealthScore } from "./health.js";
4
+ // Full-coverage scores are never null; narrow for the existing assertions.
5
+ function num(r) {
6
+ assert.notEqual(r.score, null, "expected a numeric score");
7
+ return r.score;
8
+ }
4
9
  const defaults = {
5
10
  weights: { errorRate: 0.35, latency: 0.25, cpu: 0.20, logErrors: 0.20 },
6
11
  cpu: { good: 50, warn: 80, crit: 95 },
@@ -22,21 +27,21 @@ describe("calculateHealthScore", () => {
22
27
  cpu: 20, memory: 100_000_000, errorRate: 0.005, latencyP99: 0.3, logErrorRate: 0,
23
28
  }, defaults);
24
29
  assert.equal(result.status, "healthy");
25
- assert.ok(result.score > 80);
30
+ assert.ok(num(result) > 80);
26
31
  });
27
32
  it("returns degraded for elevated values", () => {
28
33
  const result = calculateHealthScore({
29
34
  cpu: 65, memory: 200_000_000, errorRate: 0.05, latencyP99: 0.8, logErrorRate: 3,
30
35
  }, defaults);
31
36
  assert.equal(result.status, "degraded");
32
- assert.ok(result.score > 50 && result.score <= 80, `Expected degraded score 50-80, got ${result.score}`);
37
+ assert.ok(num(result) > 50 && num(result) <= 80, `Expected degraded score 50-80, got ${result.score}`);
33
38
  });
34
39
  it("returns critical for extreme values", () => {
35
40
  const result = calculateHealthScore({
36
41
  cpu: 98, memory: 500_000_000, errorRate: 1.0, latencyP99: 5.0, logErrorRate: 50,
37
42
  }, defaults);
38
43
  assert.equal(result.status, "critical");
39
- assert.ok(result.score <= 50);
44
+ assert.ok(num(result) <= 50);
40
45
  });
41
46
  it("score is between 0 and 100", () => {
42
47
  for (const cpu of [0, 50, 100]) {
@@ -44,7 +49,7 @@ describe("calculateHealthScore", () => {
44
49
  const result = calculateHealthScore({
45
50
  cpu, memory: 0, errorRate: err, latencyP99: 0, logErrorRate: 0,
46
51
  }, defaults);
47
- assert.ok(result.score >= 0 && result.score <= 100, `Score ${result.score} out of range for cpu=${cpu} err=${err}`);
52
+ assert.ok(num(result) >= 0 && num(result) <= 100, `Score ${result.score} out of range`);
48
53
  }
49
54
  }
50
55
  });
@@ -57,7 +62,7 @@ describe("calculateHealthScore", () => {
57
62
  cpu: 25, memory: 0, errorRate: 0, latencyP99: 0, logErrorRate: 0,
58
63
  }, strict);
59
64
  // CPU 25% with strict thresholds should lower the score
60
- assert.ok(result.score < 100);
65
+ assert.ok(num(result) < 100);
61
66
  });
62
67
  it("includes detail breakdown", () => {
63
68
  const result = calculateHealthScore({
@@ -67,4 +72,25 @@ describe("calculateHealthScore", () => {
67
72
  assert.ok("errorRate" in result.details);
68
73
  assert.ok(result.details.cpu.score < 100);
69
74
  });
75
+ it("coverage: no signals at all → score null, status unknown (issue #453)", () => {
76
+ const r = calculateHealthScore({ cpu: 0, memory: 0, errorRate: 0, latencyP99: 0, logErrorRate: 0 }, defaults, { metrics: false, logs: false });
77
+ assert.equal(r.score, null);
78
+ assert.equal(r.status, "unknown");
79
+ assert.deepEqual(r.details, {});
80
+ });
81
+ it("coverage: log-only service is judged on logs, not metric zeros (issue #453)", () => {
82
+ // High log error rate, no metric coverage → must NOT come back healthy.
83
+ const r = calculateHealthScore({ cpu: 0, memory: 0, errorRate: 0, latencyP99: 0, logErrorRate: 50 }, defaults, { metrics: false, logs: true });
84
+ assert.notEqual(r.status, "healthy");
85
+ assert.ok(num(r) < 50, `log-only with 50 errors/min should not be healthy, got ${r.score}`);
86
+ assert.ok(!("cpu" in r.details), "metric details excluded when metrics absent");
87
+ assert.ok("logErrors" in r.details);
88
+ });
89
+ it("coverage: full coverage (default) is unchanged by the coverage param", () => {
90
+ const inputs = { cpu: 65, memory: 0, errorRate: 0.05, latencyP99: 0.8, logErrorRate: 3 };
91
+ const implicit = calculateHealthScore(inputs, defaults);
92
+ const explicit = calculateHealthScore(inputs, defaults, { metrics: true, logs: true });
93
+ assert.equal(implicit.score, explicit.score);
94
+ assert.equal(implicit.status, explicit.status);
95
+ });
70
96
  });
@@ -38,7 +38,7 @@ describe("embeddable analysis library", () => {
38
38
  logErrors: { good: 1, warn: 5, crit: 10 },
39
39
  statusBoundaries: { healthy: 80, degraded: 50 },
40
40
  });
41
- assert.ok(r.score >= 0 && r.score <= 100);
41
+ assert.ok(r.score !== null && r.score >= 0 && r.score <= 100);
42
42
  assert.ok(["healthy", "degraded", "critical"].includes(r.status));
43
43
  });
44
44
  });
@@ -428,3 +428,14 @@ test("E2E: /llms.txt is served and reflects the canonical tool registry", opts,
428
428
  }
429
429
  assert.ok(text.includes("for-agents"), "must link the for-agents guide");
430
430
  });
431
+ test("E2E: initialize advertises non-empty instructions pointing at the usage guide (issue #455)", opts, async () => {
432
+ const { response } = await jsonRpc("initialize", {
433
+ protocolVersion: PROTOCOL_VERSION,
434
+ capabilities: {},
435
+ clientInfo: { name: "harness", version: "0" },
436
+ }, { id: 30 });
437
+ const r = response.result;
438
+ assert.ok(r.instructions && r.instructions.length > 0, "initialize.instructions must be populated");
439
+ assert.match(r.instructions, /omcp:\/\/guide\/agent-usage/, "must point at the usage-guide resource");
440
+ assert.match(r.instructions, /aggregate/i, "must carry the filter+aggregate golden rule");
441
+ });
@@ -68,7 +68,12 @@ export function buildAggregateLogQL(streamPipeline, agg, duration) {
68
68
  if (agg.op === "count_over_time") {
69
69
  const stepSec = (agg.step && parseDurationSeconds(agg.step)) || defaultBucketSeconds(durSec);
70
70
  const inner = `count_over_time(${streamPipeline} [${stepSec}s])`;
71
- const logql = byClause ? `sum${byClause} (${inner})` : inner;
71
+ // Always wrap in sum() — even with no `by`. A bare count_over_time over a
72
+ // `| json`-piped stream keeps every extracted label (rid/ip/status/…) as
73
+ // its own series, so the "requests over time" headline case returns a
74
+ // high-cardinality mess instead of one bucketed total (issue #452). With
75
+ // no `by` we collapse to a single series; `sum by(...)` for explicit by.
76
+ const logql = `sum${byClause} (${inner})`;
72
77
  return { logql, mode: "range", step: `${stepSec}s` };
73
78
  }
74
79
  // sum / topk: count over the whole window, then aggregate → instant vector.
@@ -195,6 +200,16 @@ export class LokiConnector {
195
200
  const url = `/loki/api/v1/query_range?query=${encodeURIComponent(logql)}` +
196
201
  `&start=${start}000000000&end=${end}000000000&limit=${limit}`;
197
202
  const data = await this.apiGet(url);
203
+ // A log query yields resultType "streams". A metric query (e.g. a
204
+ // raw_query wrapping sum()/count() → vector/matrix) does NOT — and the
205
+ // streams parser below would dereference undefined `.stream`/`.values`
206
+ // and crash on `.level` (issue #452). Fail fast with a clear, actionable
207
+ // message instead of running to timeout on a wrong-shaped result.
208
+ const resultType = data?.data?.resultType;
209
+ if (resultType && resultType !== "streams") {
210
+ throw new Error(`query_logs raw_query returned a '${resultType}' result, but query_logs handles log lines (streams) only. ` +
211
+ "For counts/sums/top-k use the `aggregate` param on query_logs; for arbitrary vector/matrix LogQL use query_metrics raw_query.");
212
+ }
198
213
  const entries = [];
199
214
  for (const stream of data?.data?.result || []) {
200
215
  const labels = stream.stream;
@@ -96,6 +96,34 @@ describe("Q-LOG1: queryLogs LogQL assembly", () => {
96
96
  });
97
97
  assert.equal(q, '{job="raw"}');
98
98
  });
99
+ it("#452: rawQuery returning a vector/matrix fails fast with a clear message (no .level crash)", async () => {
100
+ const conn = new LokiConnector();
101
+ await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
102
+ const orig = globalThis.fetch;
103
+ globalThis.fetch = (async (url) => {
104
+ const u = String(url);
105
+ if (u.includes("/label/") && u.includes("/values"))
106
+ return jsonRes({ data: ["app"] });
107
+ // A metric raw_query (sum(count_over_time(...))) returns resultType matrix,
108
+ // not streams — must not be fed to the streams parser.
109
+ if (u.includes("/query_range")) {
110
+ return jsonRes({ data: { resultType: "matrix", result: [{ metric: { url: "/" }, values: [[1000, "5"]] }] } });
111
+ }
112
+ return jsonRes({ data: [] });
113
+ });
114
+ try {
115
+ await conn.queryLogs({ rawQuery: "sum(count_over_time({app=\"x\"} | json [6h]))", duration: "6h" });
116
+ assert.fail("expected a thrown error for a non-streams raw_query result");
117
+ }
118
+ catch (err) {
119
+ const msg = err instanceof Error ? err.message : String(err);
120
+ assert.match(msg, /'matrix' result/);
121
+ assert.match(msg, /aggregate.*param|query_metrics raw_query/);
122
+ }
123
+ finally {
124
+ globalThis.fetch = orig;
125
+ }
126
+ });
99
127
  });
100
128
  describe("Q-LOG2: parseDurationSeconds / defaultBucketSeconds", () => {
101
129
  it("parses m/h/d", () => {
@@ -118,11 +146,14 @@ describe("Q-LOG2: buildAggregateLogQL", () => {
118
146
  assert.equal(r.step, "900s");
119
147
  assert.equal(r.logql, `sum by (url) (count_over_time(${PIPE} [900s]))`);
120
148
  });
121
- it("count_over_time without by → bare count_over_time, default step", () => {
149
+ it("count_over_time without by → sum-wrapped (single series), default step (#452)", () => {
150
+ // Regression for issue #452: a bare count_over_time over a `| json` stream
151
+ // keeps every extracted label as its own series. With no `by` we must
152
+ // collapse to one bucketed total via sum(...).
122
153
  const r = buildAggregateLogQL(PIPE, { op: "count_over_time" }, "1h");
123
154
  assert.equal(r.mode, "range");
124
155
  assert.equal(r.step, "60s");
125
- assert.equal(r.logql, `count_over_time(${PIPE} [60s])`);
156
+ assert.equal(r.logql, `sum (count_over_time(${PIPE} [60s]))`);
126
157
  });
127
158
  it("sum → instant total per group over the whole window", () => {
128
159
  const r = buildAggregateLogQL(PIPE, { op: "sum", by: ["status"] }, "1h");
package/dist/index.js CHANGED
@@ -342,6 +342,18 @@ async function main() {
342
342
  const mcpServer = new McpServer({
343
343
  name: "observability-mcp",
344
344
  version: SERVER_VERSION,
345
+ }, {
346
+ // `instructions` is the one channel the MCP spec auto-injects into the
347
+ // agent's context on connect (issue #455). Keep it tight: point at the
348
+ // full guide resource + the single rule that prevents the most common
349
+ // mistake (dumping raw rows), + the empty-state contract.
350
+ instructions: "Read MCP resource `omcp://guide/agent-usage` before heavy use. " +
351
+ "Golden rule: filter + aggregate server-side — use `query_logs`/`query_metrics` " +
352
+ "`labels` and `query_logs` `aggregate` to ask for numbers, not haystacks (raw log " +
353
+ "dumps blow past context limits). All tools are read-only. When a result is empty " +
354
+ "or refused, the message names the operator flag that unlocks it (e.g. OMCP_RAW_QUERY) " +
355
+ "— relay it verbatim. Prompts `triage-incident` and `write-postmortem` compose the tools. " +
356
+ "Hit a rough edge? Report it via the agent-report issue template (see the guide).",
345
357
  });
346
358
  const toolHandlers = new Map();
347
359
  // --- Register tools with Zod schemas ---
@@ -431,9 +443,12 @@ async function main() {
431
443
  "redacted values → a `_redacted` count in the result. Relay flag names to your",
432
444
  "operator verbatim — the messages are written to be forwarded.",
433
445
  "",
434
- "## Report findings",
435
- "Structured agent reports drive releases here (see issue #415). File one:",
446
+ "## Report findings & collaborate",
447
+ "Structured agent reports drive releases here (see issue #415). File a bug or gap:",
436
448
  "https://github.com/ThoTischner/observability-mcp/issues/new?template=agent-report.yml",
449
+ "For workflows, ideas, or to compare notes with other agents (collaboration welcome):",
450
+ "https://github.com/ThoTischner/observability-mcp/discussions",
451
+ "Note: posting publicly is an outward-facing action — do it on your operator's behalf, not unprompted.",
437
452
  "Full guide: https://thotischner.github.io/observability-mcp/for-agents/",
438
453
  ].join("\n"),
439
454
  },
@@ -546,6 +561,7 @@ async function main() {
546
561
  registerTool("query_logs", [
547
562
  "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).",
548
563
  "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.",
564
+ "Golden rule: filter + aggregate server-side — pass `labels` to scope and `aggregate` (count_over_time/sum/topk) to get numbers, not raw rows. A high-volume window returned raw will blow past your context limit.",
549
565
  "Prerequisites: get the exact service name from `list_services` (the service must expose a logs signal).",
550
566
  "Behavior: read-only, no side effects. Returns the matching log entries (newest first, capped by `limit`) plus a summary with total/error/warn counts and top recurring error patterns. No matches yields an empty result with a zeroed summary; an unreachable backend yields a structured explanatory error, never an exception.",
551
567
  ].join(" "), {
@@ -40,18 +40,42 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
40
40
  const tenantConnectors = registry.getByTenant(ctx.tenant);
41
41
  const metricsConnectors = tenantConnectors.filter((c) => c.signalType === "metrics");
42
42
  const logConnectors = tenantConnectors.filter((c) => c.signalType === "logs");
43
- let serviceNames = [];
43
+ // Discover services from BOTH metrics and log connectors, tracking which
44
+ // signal each service exposes. Previously the fleet scan only enumerated
45
+ // metrics connectors, so a log-only service was silently dropped from the
46
+ // scan — and the "all healthy" all-clear never said so (issue #453B). Now
47
+ // log-only services are scanned (via their log error-rate, as the
48
+ // description promises) and the per-service coverage is reported.
49
+ const coverage = new Map();
50
+ const mark = (name, key) => {
51
+ const e = coverage.get(name) ?? { metrics: false, logs: false };
52
+ e[key] = true;
53
+ coverage.set(name, e);
54
+ };
55
+ for (const connector of metricsConnectors) {
56
+ try {
57
+ for (const s of await connector.listServices())
58
+ mark(s.name, "metrics");
59
+ }
60
+ catch { /* connector down — skip */ }
61
+ }
62
+ for (const connector of logConnectors) {
63
+ try {
64
+ for (const s of await connector.listServices())
65
+ mark(s.name, "logs");
66
+ }
67
+ catch { /* connector down — skip */ }
68
+ }
69
+ let serviceNames;
44
70
  if (args.service) {
45
71
  serviceNames = [args.service];
72
+ if (!coverage.has(args.service)) {
73
+ // Unknown to listServices — still attempt both signal paths.
74
+ coverage.set(args.service, { metrics: metricsConnectors.length > 0, logs: logConnectors.length > 0 });
75
+ }
46
76
  }
47
77
  else {
48
- for (const connector of metricsConnectors) {
49
- const services = await connector.listServices();
50
- for (const s of services) {
51
- if (!serviceNames.includes(s.name))
52
- serviceNames.push(s.name);
53
- }
54
- }
78
+ serviceNames = [...coverage.keys()];
55
79
  }
56
80
  const allAnomalies = [];
57
81
  const allCorrelations = [];
@@ -179,14 +203,25 @@ export async function detectAnomaliesHandler(registry, args, ctx = defaultContex
179
203
  severity: a.severity,
180
204
  })))
181
205
  : { ranked: [], summary: "" };
206
+ // Per-service coverage so an "all healthy" all-clear is verifiable rather
207
+ // than silently partial: the caller sees exactly which services were
208
+ // scanned and on which signals (issue #453B).
209
+ const scanned = serviceNames.map((name) => {
210
+ const cov = coverage.get(name) ?? { metrics: false, logs: false };
211
+ const signals = [cov.metrics ? "metrics" : null, cov.logs ? "logs" : null].filter(Boolean);
212
+ return { service: name, signals };
213
+ });
214
+ const metricsCount = scanned.filter((s) => s.signals.includes("metrics")).length;
215
+ const logsCount = scanned.filter((s) => s.signals.includes("logs")).length;
182
216
  const result = {
183
217
  scannedServices: serviceNames.length,
218
+ coverage: { scanned },
184
219
  anomalies: allAnomalies,
185
220
  correlations: allCorrelations,
186
221
  rootCause,
187
222
  summary: allAnomalies.length === 0
188
- ? "All services healthy no anomalies detected."
189
- : `${allAnomalies.length} anomal${allAnomalies.length === 1 ? "y" : "ies"} detected across ${[...new Set(allAnomalies.map((a) => a.service))].length} service(s).`,
223
+ ? `No anomalies across ${serviceNames.length} scanned service(s) (${metricsCount} with metrics, ${logsCount} with logs).`
224
+ : `${allAnomalies.length} anomal${allAnomalies.length === 1 ? "y" : "ies"} detected across ${[...new Set(allAnomalies.map((a) => a.service))].length} of ${serviceNames.length} scanned service(s).`,
190
225
  };
191
226
  return {
192
227
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -24,23 +24,38 @@ export async function getServiceHealthHandler(registry, args, ctx = defaultConte
24
24
  const tenantConnectors = registry.getByTenant(ctx.tenant);
25
25
  const metricsConnectors = tenantConnectors.filter((c) => c.signalType === "metrics");
26
26
  const logConnectors = tenantConnectors.filter((c) => c.signalType === "logs");
27
- // Gather metrics
27
+ // Gather metrics. Track whether any series actually returned data —
28
+ // absent metrics must NOT be coerced to 0 and read as a confident
29
+ // "healthy" (issue #453).
28
30
  let cpu = 0, memory = 0, errorRate = 0, latencyP99 = 0;
31
+ let metricsHadData = false;
29
32
  const anomalies = [];
30
33
  for (const connector of metricsConnectors) {
31
34
  if (!connector.queryMetrics)
32
35
  continue;
33
36
  try {
34
37
  const cpuResult = await connector.queryMetrics({ service: args.service, metric: "cpu", duration: "5m" });
35
- cpu = cpuResult.summary.current;
38
+ if (cpuResult.values.length > 0) {
39
+ cpu = cpuResult.summary.current;
40
+ metricsHadData = true;
41
+ }
36
42
  checkAnomaly(cpuResult.values.map(v => v.value), "cpu", args.service, connector.name, anomalies);
37
43
  const memResult = await connector.queryMetrics({ service: args.service, metric: "memory", duration: "5m" });
38
- memory = memResult.summary.current / 1_000_000; // Convert to MB for display
44
+ if (memResult.values.length > 0) {
45
+ memory = memResult.summary.current / 1_000_000;
46
+ metricsHadData = true;
47
+ } // MB for display
39
48
  const errResult = await connector.queryMetrics({ service: args.service, metric: "error_rate", duration: "5m" });
40
- errorRate = errResult.summary.current;
49
+ if (errResult.values.length > 0) {
50
+ errorRate = errResult.summary.current;
51
+ metricsHadData = true;
52
+ }
41
53
  checkAnomaly(errResult.values.map(v => v.value), "error_rate", args.service, connector.name, anomalies);
42
54
  const latResult = await connector.queryMetrics({ service: args.service, metric: "latency_p99", duration: "5m" });
43
- latencyP99 = latResult.summary.current;
55
+ if (latResult.values.length > 0) {
56
+ latencyP99 = latResult.summary.current;
57
+ metricsHadData = true;
58
+ }
44
59
  checkAnomaly(latResult.values.map(v => v.value), "latency_p99", args.service, connector.name, anomalies);
45
60
  }
46
61
  catch (err) {
@@ -50,12 +65,15 @@ export async function getServiceHealthHandler(registry, args, ctx = defaultConte
50
65
  // Gather logs
51
66
  let logErrorRate = 0;
52
67
  let topErrors = [];
68
+ let logsHadData = false;
53
69
  const correlations = [];
54
70
  for (const connector of logConnectors) {
55
71
  if (!connector.queryLogs)
56
72
  continue;
57
73
  try {
58
74
  const logs = await connector.queryLogs({ service: args.service, duration: "5m", limit: 200 });
75
+ if (logs.summary.total > 0)
76
+ logsHadData = true; // real log coverage in the window
59
77
  logErrorRate = logs.summary.errorCount; // errors in 5m window
60
78
  topErrors = logs.summary.topPatterns;
61
79
  // Cross-signal correlation
@@ -70,7 +88,31 @@ export async function getServiceHealthHandler(registry, args, ctx = defaultConte
70
88
  console.error("Health check logs failed for %s:", sanitizeForLog(args.service), err);
71
89
  }
72
90
  }
73
- // Calculate health score
91
+ // Honest signal coverage: judge the service only on the families that
92
+ // actually returned data, so a log-only (or absent) service is never
93
+ // coerced to a confident "healthy" from metric zeros (issue #453).
94
+ const coverage = { metrics: metricsHadData, logs: logsHadData };
95
+ // No data at all → either the service doesn't exist (typo / decommissioned)
96
+ // or it isn't monitored. Say so explicitly, like the other tools' empty
97
+ // states — don't return 100/healthy.
98
+ if (!metricsHadData && !logsHadData) {
99
+ const known = await knownServiceNames(tenantConnectors, args.service);
100
+ const note = known
101
+ ? `No metric or log data for "${args.service}" in the last 5 minutes — the service exists but has no monitored signals (or was quiet). Health is unknown, not healthy.`
102
+ : `Service "${args.service}" was not found in any connected source. Check the exact name via list_services. (Not reporting a health score for a service that does not exist.)`;
103
+ const result = {
104
+ service: args.service,
105
+ status: "unknown",
106
+ score: null,
107
+ signals: { metrics: null, logs: null },
108
+ anomalies,
109
+ correlations,
110
+ coverage,
111
+ note,
112
+ };
113
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
114
+ }
115
+ // Calculate health score over the covered signals only.
74
116
  const { DEFAULT_HEALTH_THRESHOLDS } = await import("../config/loader.js");
75
117
  const health = calculateHealthScore({
76
118
  cpu,
@@ -78,22 +120,47 @@ export async function getServiceHealthHandler(registry, args, ctx = defaultConte
78
120
  errorRate,
79
121
  latencyP99,
80
122
  logErrorRate,
81
- }, _thresholds || DEFAULT_HEALTH_THRESHOLDS);
123
+ }, _thresholds || DEFAULT_HEALTH_THRESHOLDS, coverage);
82
124
  const result = {
83
125
  service: args.service,
84
126
  status: health.status,
85
127
  score: health.score,
86
128
  signals: {
87
- metrics: { cpu, memory, errorRate, latencyP99 },
88
- logs: { errorRate: logErrorRate, topErrors },
129
+ metrics: metricsHadData ? { cpu, memory, errorRate, latencyP99 } : null,
130
+ logs: logsHadData ? { errorRate: logErrorRate, topErrors } : null,
89
131
  },
90
132
  anomalies,
91
133
  correlations,
134
+ coverage,
135
+ note: !metricsHadData
136
+ ? "No metrics signal for this service — score reflects logs only."
137
+ : !logsHadData
138
+ ? "No logs signal for this service — score reflects metrics only."
139
+ : undefined,
92
140
  };
93
141
  return {
94
142
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
95
143
  };
96
144
  }
145
+ /** Best-effort: does any connector in the tenant know this service name?
146
+ * Used only on the no-data path to distinguish "exists but unmonitored/quiet"
147
+ * from "doesn't exist (typo/decommissioned)". A connector that throws is
148
+ * treated as "can't confirm" and skipped. */
149
+ async function knownServiceNames(connectors, service) {
150
+ for (const c of connectors) {
151
+ if (!c.listServices)
152
+ continue;
153
+ try {
154
+ const svcs = await c.listServices();
155
+ if (svcs.some((s) => s.name === service))
156
+ return true;
157
+ }
158
+ catch {
159
+ /* can't confirm via this connector — keep checking */
160
+ }
161
+ }
162
+ return false;
163
+ }
97
164
  function checkAnomaly(values, metric, service, source, anomalies) {
98
165
  // Robust, metric-type-aware detector (same path as detect_anomalies):
99
166
  // latency/error_rate/saturation are one-sided, so a *decrease* (e.g.
@@ -153,6 +153,38 @@ describe("listServicesHandler", () => {
153
153
  assert.equal(data.total, 0);
154
154
  });
155
155
  });
156
+ describe("detectAnomaliesHandler — fleet coverage (issue #453B)", () => {
157
+ it("fleet scan includes log-only services and reports per-service coverage", async () => {
158
+ const reg = createRegistryWithMocks([
159
+ createMockConnector({
160
+ name: "prom1", type: "prometheus", signalType: "metrics",
161
+ listServices: async () => [{ name: "metric-svc", source: "prom1", signalType: "metrics" }],
162
+ queryMetrics: async () => ({
163
+ source: "prom1", service: "metric-svc", metric: "x", unit: "",
164
+ values: Array.from({ length: 30 }, (_, i) => ({ timestamp: new Date(Date.now() - (30 - i) * 9000).toISOString(), value: 20 })),
165
+ summary: { current: 20, average: 20, min: 20, max: 20, trend: "stable" },
166
+ }),
167
+ }),
168
+ createMockConnector({
169
+ name: "loki1", type: "loki", signalType: "logs",
170
+ listServices: async () => [{ name: "log-only-svc", source: "loki1", signalType: "logs" }],
171
+ queryLogs: async () => ({
172
+ source: "loki1", service: "log-only-svc", entries: [],
173
+ summary: { total: 10, errorCount: 0, warnCount: 0, topPatterns: [] },
174
+ }),
175
+ }),
176
+ ]);
177
+ const data = JSON.parse((await detectAnomaliesHandler(reg, {})).content[0].text);
178
+ // Both services scanned — the log-only one is NOT silently dropped.
179
+ assert.equal(data.scannedServices, 2);
180
+ const names = data.coverage.scanned.map((s) => s.service).sort();
181
+ assert.deepEqual(names, ["log-only-svc", "metric-svc"]);
182
+ const logOnly = data.coverage.scanned.find((s) => s.service === "log-only-svc");
183
+ assert.deepEqual(logOnly.signals, ["logs"], "log-only service must be scanned via its logs signal");
184
+ // All-clear is no longer silently partial — it states the coverage.
185
+ assert.match(data.summary, /2 scanned service\(s\)/);
186
+ });
187
+ });
156
188
  describe("detectAnomaliesHandler — A5 memory/OOM coverage", () => {
157
189
  const flatMemory = () => ({
158
190
  source: "prom1", service: "payment-service", metric: "memory", unit: "bytes",
@@ -255,3 +287,57 @@ describe("getServiceHealthHandler — one-sided latency (regression)", () => {
255
287
  assert.equal(latAnom, undefined, `latency dropping must not be an anomaly, got: ${JSON.stringify(latAnom)}`);
256
288
  });
257
289
  });
290
+ describe("getServiceHealthHandler — honest no-data / not-found (issue #453)", () => {
291
+ const emptySeries = () => ({
292
+ source: "prom1", service: "x", metric: "x", unit: "",
293
+ values: [],
294
+ summary: { current: 0, average: 0, min: 0, max: 0, trend: "stable" },
295
+ });
296
+ function metricsConnector(known) {
297
+ return {
298
+ connect: async () => { }, disconnect: async () => { },
299
+ healthCheck: async () => ({ status: "up", latencyMs: 1 }),
300
+ getDefaultMetrics: () => [], getMetrics: () => [],
301
+ listServices: async () => known.map((name) => ({ name, source: "prom1", signalType: "metrics" })),
302
+ name: "prom1", type: "prometheus", signalType: "metrics",
303
+ queryMetrics: async () => emptySeries(), // no data for any metric
304
+ };
305
+ }
306
+ function regWith(...mocks) {
307
+ const reg = new ConnectorRegistry();
308
+ for (const m of mocks) {
309
+ reg.connectors.set(m.name, m);
310
+ reg.sourceConfigs.set(m.name, { name: m.name, type: m.type, url: "http://m", enabled: true });
311
+ }
312
+ return reg;
313
+ }
314
+ it("nonexistent service → status unknown, score null, not-found note (not 100/healthy)", async () => {
315
+ const reg = regWith(metricsConnector(["payment-service"])); // does NOT know the queried name
316
+ const data = JSON.parse((await getServiceHealthHandler(reg, { service: "nope-xyz" })).content[0].text);
317
+ assert.equal(data.status, "unknown");
318
+ assert.equal(data.score, null);
319
+ assert.equal(data.signals.metrics, null);
320
+ assert.match(data.note, /not found/i);
321
+ });
322
+ it("log-only service with errors → judged on logs, never 100/healthy from metric zeros", async () => {
323
+ const logs = {
324
+ connect: async () => { }, disconnect: async () => { },
325
+ healthCheck: async () => ({ status: "up", latencyMs: 1 }),
326
+ getDefaultMetrics: () => [], getMetrics: () => [],
327
+ listServices: async () => [{ name: "logapp", source: "loki1", signalType: "logs" }],
328
+ name: "loki1", type: "loki", signalType: "logs",
329
+ queryLogs: async () => ({
330
+ source: "loki1", service: "logapp", entries: [],
331
+ summary: { total: 60, errorCount: 40, warnCount: 0, topPatterns: ["boom"] },
332
+ }),
333
+ };
334
+ const reg = regWith(metricsConnector([]), logs);
335
+ const data = JSON.parse((await getServiceHealthHandler(reg, { service: "logapp" })).content[0].text);
336
+ assert.notEqual(data.status, "healthy");
337
+ assert.notEqual(data.status, "unknown");
338
+ assert.equal(data.signals.metrics, null, "metrics signal must be null when no metric data");
339
+ assert.ok(data.signals.logs, "logs signal must be present");
340
+ assert.deepEqual(data.coverage, { metrics: false, logs: true });
341
+ assert.ok(data.score !== null && data.score < 50, `40 errors/5min log-only must not be healthy, got ${data.score}`);
342
+ });
343
+ });
package/dist/types.d.ts CHANGED
@@ -338,20 +338,31 @@ export interface AnomalyReport {
338
338
  }
339
339
  export interface ServiceHealth {
340
340
  service: string;
341
- status: HealthStatus;
342
- score: number;
341
+ /** "unknown" when the service has no data in any signal (or doesn't exist). */
342
+ status: HealthStatus | "unknown";
343
+ /** 0-100, or null when status is "unknown" (no signal had data). */
344
+ score: number | null;
343
345
  signals: {
346
+ /** null when the service exposes no metrics signal / no metric data. */
344
347
  metrics: {
345
348
  cpu: number;
346
349
  memory: number;
347
350
  errorRate: number;
348
351
  latencyP99: number;
349
- };
352
+ } | null;
353
+ /** null when the service exposes no logs signal / no log data. */
350
354
  logs: {
351
355
  errorRate: number;
352
356
  topErrors: string[];
353
- };
357
+ } | null;
354
358
  };
355
359
  anomalies: AnomalyReport[];
356
360
  correlations: string[];
361
+ /** Which signal families actually had data (drives the score weighting). */
362
+ coverage?: {
363
+ metrics: boolean;
364
+ logs: boolean;
365
+ };
366
+ /** Operator-facing explanation when status is "unknown" or coverage is partial. */
367
+ note?: string;
357
368
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "Unified observability gateway for AI agents — one MCP server for Prometheus, Loki, and any backend",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",