feat: add Loki log query support to lab-monitoring
Add 3 opt-in Loki tools (query_logs, list_labels, list_label_values) that are registered when LOKI_URL is configured. Includes Loki HTTP client, CLI commands (logs, labels), NixOS module option, formatting, and tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers.
|
||||
func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc) (*mcp.Server, func()) {
|
||||
func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, lokiHandler ...http.HandlerFunc) (*mcp.Server, func()) {
|
||||
t.Helper()
|
||||
|
||||
promSrv := httptest.NewServer(promHandler)
|
||||
@@ -26,11 +26,22 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc) (*mc
|
||||
|
||||
prom := NewPrometheusClient(promSrv.URL)
|
||||
am := NewAlertmanagerClient(amSrv.URL)
|
||||
RegisterHandlers(server, prom, am, HandlerOptions{EnableSilences: true})
|
||||
|
||||
var loki *LokiClient
|
||||
var lokiSrv *httptest.Server
|
||||
if len(lokiHandler) > 0 && lokiHandler[0] != nil {
|
||||
lokiSrv = httptest.NewServer(lokiHandler[0])
|
||||
loki = NewLokiClient(lokiSrv.URL)
|
||||
}
|
||||
|
||||
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})
|
||||
|
||||
cleanup := func() {
|
||||
promSrv.Close()
|
||||
amSrv.Close()
|
||||
if lokiSrv != nil {
|
||||
lokiSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return server, cleanup
|
||||
@@ -305,8 +316,9 @@ func TestHandler_ToolCount(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
tools := listTools(t, server)
|
||||
// Without Loki: 7 base + 1 silence = 8
|
||||
if len(tools) != 8 {
|
||||
t.Errorf("expected 8 tools with silences enabled, got %d", len(tools))
|
||||
t.Errorf("expected 8 tools with silences enabled (no Loki), got %d", len(tools))
|
||||
for _, tool := range tools {
|
||||
t.Logf(" tool: %s", tool.Name)
|
||||
}
|
||||
@@ -325,6 +337,37 @@ func TestHandler_ToolCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ToolCountWithLoki(t *testing.T) {
|
||||
server, cleanup := setupTestServer(t,
|
||||
func(w http.ResponseWriter, r *http.Request) {},
|
||||
func(w http.ResponseWriter, r *http.Request) {},
|
||||
func(w http.ResponseWriter, r *http.Request) {},
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
tools := listTools(t, server)
|
||||
// With Loki: 7 base + 1 silence + 3 loki = 11
|
||||
if len(tools) != 11 {
|
||||
t.Errorf("expected 11 tools with silences and Loki enabled, got %d", len(tools))
|
||||
for _, tool := range tools {
|
||||
t.Logf(" tool: %s", tool.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Loki tools are present
|
||||
lokiTools := map[string]bool{"query_logs": false, "list_labels": false, "list_label_values": false}
|
||||
for _, tool := range tools {
|
||||
if _, ok := lokiTools[tool.Name]; ok {
|
||||
lokiTools[tool.Name] = true
|
||||
}
|
||||
}
|
||||
for name, found := range lokiTools {
|
||||
if !found {
|
||||
t.Errorf("expected %s tool when Loki enabled", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ToolCountWithoutSilences(t *testing.T) {
|
||||
promSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
amSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
@@ -337,7 +380,7 @@ func TestHandler_ToolCountWithoutSilences(t *testing.T) {
|
||||
|
||||
prom := NewPrometheusClient(promSrv.URL)
|
||||
am := NewAlertmanagerClient(amSrv.URL)
|
||||
RegisterHandlers(server, prom, am, HandlerOptions{EnableSilences: false})
|
||||
RegisterHandlers(server, prom, am, nil, HandlerOptions{EnableSilences: false})
|
||||
|
||||
tools := listTools(t, server)
|
||||
if len(tools) != 7 {
|
||||
@@ -384,6 +427,110 @@ func listTools(t *testing.T, server *mcp.Server) []mcp.Tool {
|
||||
return listResult.Tools
|
||||
}
|
||||
|
||||
func TestHandler_QueryLogs(t *testing.T) {
|
||||
server, cleanup := setupTestServer(t,
|
||||
nil,
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/loki/api/v1/query_range" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"resultType": "streams",
|
||||
"result": [
|
||||
{
|
||||
"stream": {"job": "varlogs", "filename": "/var/log/syslog"},
|
||||
"values": [
|
||||
["1704067200000000000", "Jan 1 00:00:00 host kernel: test message"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
},
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
result := callTool(t, server, "query_logs", map[string]interface{}{
|
||||
"logql": `{job="varlogs"}`,
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "varlogs") {
|
||||
t.Errorf("expected output to contain 'varlogs', got: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "test message") {
|
||||
t.Errorf("expected output to contain 'test message', got: %s", result.Content[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListLabels(t *testing.T) {
|
||||
server, cleanup := setupTestServer(t,
|
||||
nil,
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/loki/api/v1/labels" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"status": "success",
|
||||
"data": ["job", "instance", "filename"]
|
||||
}`))
|
||||
},
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
result := callTool(t, server, "list_labels", map[string]interface{}{})
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "3 label") {
|
||||
t.Errorf("expected output to contain '3 label', got: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "job") {
|
||||
t.Errorf("expected output to contain 'job', got: %s", result.Content[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ListLabelValues(t *testing.T) {
|
||||
server, cleanup := setupTestServer(t,
|
||||
nil,
|
||||
nil,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/loki/api/v1/label/job/values" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"status": "success",
|
||||
"data": ["varlogs", "nginx", "systemd"]
|
||||
}`))
|
||||
},
|
||||
)
|
||||
defer cleanup()
|
||||
|
||||
result := callTool(t, server, "list_label_values", map[string]interface{}{
|
||||
"label": "job",
|
||||
})
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "3 value") {
|
||||
t.Errorf("expected output to contain '3 value', got: %s", result.Content[0].Text)
|
||||
}
|
||||
if !strings.Contains(result.Content[0].Text, "nginx") {
|
||||
t.Errorf("expected output to contain 'nginx', got: %s", result.Content[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// callTool is a test helper that calls a tool through the MCP server.
|
||||
func callTool(t *testing.T, server *mcp.Server, name string, args map[string]interface{}) mcp.CallToolResult {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user