@thotischner/observability-mcp 1.1.1 → 1.1.3

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,6 +7,8 @@ export declare class LokiConnector implements ObservabilityConnector {
7
7
  private baseUrl;
8
8
  private auth?;
9
9
  private tlsAgent?;
10
+ private serviceLabels;
11
+ private labelValuesCache;
10
12
  connect(config: SourceConfig): Promise<void>;
11
13
  getDefaultMetrics(): MetricDefinition[];
12
14
  getMetrics(): MetricDefinition[];
@@ -15,6 +17,8 @@ export declare class LokiConnector implements ObservabilityConnector {
15
17
  disconnect(): Promise<void>;
16
18
  listServices(): Promise<ServiceInfo[]>;
17
19
  queryLogs(params: LogQuery): Promise<LogResult>;
20
+ private getLabelValues;
21
+ private resolveServiceSelector;
18
22
  private parseLine;
19
23
  private extractTopPatterns;
20
24
  private parseTimeRange;
@@ -1,4 +1,6 @@
1
1
  import { buildTlsAgent } from "./tls.js";
2
+ const DEFAULT_SERVICE_LABELS = ["service_name", "service", "job", "app", "container"];
3
+ const LABEL_CACHE_TTL_MS = 60_000;
2
4
  export class LokiConnector {
3
5
  type = "loki";
4
6
  signalType = "logs";
@@ -6,11 +8,17 @@ export class LokiConnector {
6
8
  baseUrl = "";
7
9
  auth;
8
10
  tlsAgent;
11
+ serviceLabels = DEFAULT_SERVICE_LABELS;
12
+ labelValuesCache = new Map();
9
13
  async connect(config) {
10
14
  this.name = config.name;
11
15
  this.baseUrl = config.url.replace(/\/$/, "");
12
16
  this.auth = config.auth;
13
17
  this.tlsAgent = buildTlsAgent(config);
18
+ const envLabels = process.env.LOKI_SERVICE_LABELS;
19
+ if (envLabels) {
20
+ this.serviceLabels = envLabels.split(",").map((s) => s.trim()).filter(Boolean);
21
+ }
14
22
  }
15
23
  getDefaultMetrics() {
16
24
  // Loki is a log backend — no metric definitions by default
@@ -46,23 +54,40 @@ export class LokiConnector {
46
54
  }
47
55
  async disconnect() { }
48
56
  async listServices() {
49
- try {
50
- const data = await this.apiGet("/loki/api/v1/label/service/values");
51
- return (data?.data || []).map((name) => ({
52
- name,
53
- source: this.name,
54
- signalType: "logs",
55
- }));
56
- }
57
- catch {
58
- return [];
57
+ // Probe each candidate label and merge values. Loki streams may identify
58
+ // services via service_name, service, job, app, or container depending on
59
+ // the shipper configuration. Walking all candidates ensures historical
60
+ // streams remain reachable when label conventions change over time.
61
+ const seen = new Map();
62
+ for (const label of this.serviceLabels) {
63
+ const values = await this.getLabelValues(label);
64
+ for (const raw of values) {
65
+ // Docker's loki.source.docker writes container names with a leading '/'
66
+ // (Docker API Names[0] convention). Strip it for display so the name
67
+ // matches what the service-name validator and users will pass back in.
68
+ const display = label === "container" ? raw.replace(/^\//, "") : raw;
69
+ if (!seen.has(display)) {
70
+ seen.set(display, {
71
+ name: display,
72
+ source: this.name,
73
+ signalType: "logs",
74
+ labels: { discoveredVia: label },
75
+ });
76
+ }
77
+ }
59
78
  }
79
+ return Array.from(seen.values());
60
80
  }
61
81
  async queryLogs(params) {
62
82
  const { start, end } = this.parseTimeRange(params.duration);
63
83
  const limit = Math.min(Math.max(params.limit || 100, 1), 1000);
64
- const service = this.escapeLogQLValue(params.service);
65
- let logql = `{service="${service}"}`;
84
+ // Resolve label + actual selector value. For the 'container' label the
85
+ // value stored in Loki may be '/my-app-1' while the caller passes the
86
+ // sanitized 'my-app-1' — return the prefixed form so the LogQL selector
87
+ // matches the real stream.
88
+ const { label: matchedLabel, value: rawValue } = await this.resolveServiceSelector(params.service);
89
+ const service = this.escapeLogQLValue(rawValue);
90
+ let logql = `{${matchedLabel}="${service}"}`;
66
91
  if (params.level) {
67
92
  const level = this.escapeLogQLValue(params.level);
68
93
  logql += ` | json | level="${level}"`;
@@ -109,6 +134,38 @@ export class LokiConnector {
109
134
  };
110
135
  }
111
136
  // --- Private helpers ---
137
+ async getLabelValues(label) {
138
+ const cached = this.labelValuesCache.get(label);
139
+ if (cached && cached.expiresAt > Date.now()) {
140
+ return cached.values;
141
+ }
142
+ try {
143
+ const data = await this.apiGet(`/loki/api/v1/label/${encodeURIComponent(label)}/values`);
144
+ const values = data?.data || [];
145
+ this.labelValuesCache.set(label, {
146
+ values,
147
+ expiresAt: Date.now() + LABEL_CACHE_TTL_MS,
148
+ });
149
+ return values;
150
+ }
151
+ catch {
152
+ this.labelValuesCache.set(label, { values: [], expiresAt: Date.now() + LABEL_CACHE_TTL_MS });
153
+ return [];
154
+ }
155
+ }
156
+ async resolveServiceSelector(service) {
157
+ for (const label of this.serviceLabels) {
158
+ const values = await this.getLabelValues(label);
159
+ if (values.includes(service))
160
+ return { label, value: service };
161
+ // Container label values are Docker-prefixed with '/'. The caller can't
162
+ // pass that form (validator rejects '/'), so probe the prefixed variant.
163
+ if (label === "container" && values.includes(`/${service}`)) {
164
+ return { label, value: `/${service}` };
165
+ }
166
+ }
167
+ return { label: this.serviceLabels[0] || "service_name", value: service };
168
+ }
112
169
  parseLine(line) {
113
170
  try {
114
171
  return JSON.parse(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thotischner/observability-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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": "MIT",