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:
@@ -62,7 +62,7 @@ type HandlerOptions struct {
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all monitoring tool handlers on the MCP server.
|
||||
func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *AlertmanagerClient, opts HandlerOptions) {
|
||||
func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *AlertmanagerClient, loki *LokiClient, opts HandlerOptions) {
|
||||
server.RegisterTool(listAlertsTool(), makeListAlertsHandler(am))
|
||||
server.RegisterTool(getAlertTool(), makeGetAlertHandler(am))
|
||||
server.RegisterTool(searchMetricsTool(), makeSearchMetricsHandler(prom))
|
||||
@@ -73,6 +73,11 @@ func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *Alertmanag
|
||||
if opts.EnableSilences {
|
||||
server.RegisterTool(createSilenceTool(), makeCreateSilenceHandler(am))
|
||||
}
|
||||
if loki != nil {
|
||||
server.RegisterTool(queryLogsTool(), makeQueryLogsHandler(loki))
|
||||
server.RegisterTool(listLabelsTool(), makeListLabelsHandler(loki))
|
||||
server.RegisterTool(listLabelValuesTool(), makeListLabelValuesHandler(loki))
|
||||
}
|
||||
}
|
||||
|
||||
// Tool definitions
|
||||
@@ -485,3 +490,183 @@ func makeCreateSilenceHandler(am *AlertmanagerClient) mcp.ToolHandler {
|
||||
func parseJSON(s string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(s), v)
|
||||
}
|
||||
|
||||
// Loki tool definitions
|
||||
|
||||
func queryLogsTool() mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "query_logs",
|
||||
Description: "Execute a LogQL range query against Loki to search and retrieve log entries",
|
||||
InputSchema: mcp.InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]mcp.Property{
|
||||
"logql": {
|
||||
Type: "string",
|
||||
Description: `LogQL query expression (e.g., '{job="varlogs"}', '{job="nginx"} |= "error"')`,
|
||||
},
|
||||
"start": {
|
||||
Type: "string",
|
||||
Description: "Start time: relative duration (e.g., '1h', '30m'), RFC3339 timestamp, or Unix epoch seconds. Default: 1h ago",
|
||||
},
|
||||
"end": {
|
||||
Type: "string",
|
||||
Description: "End time: relative duration (e.g., '5m'), RFC3339 timestamp, or Unix epoch seconds. Default: now",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of log entries to return (default: 100)",
|
||||
Default: 100,
|
||||
},
|
||||
"direction": {
|
||||
Type: "string",
|
||||
Description: "Sort order for log entries: 'backward' (newest first) or 'forward' (oldest first)",
|
||||
Enum: []string{"backward", "forward"},
|
||||
},
|
||||
},
|
||||
Required: []string{"logql"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listLabelsTool() mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "list_labels",
|
||||
Description: "List available label names from Loki",
|
||||
InputSchema: mcp.InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]mcp.Property{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listLabelValuesTool() mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "list_label_values",
|
||||
Description: "List values for a specific label from Loki",
|
||||
InputSchema: mcp.InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]mcp.Property{
|
||||
"label": {
|
||||
Type: "string",
|
||||
Description: "Label name to get values for (e.g., 'job', 'instance')",
|
||||
},
|
||||
},
|
||||
Required: []string{"label"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Loki handler constructors
|
||||
|
||||
func makeQueryLogsHandler(loki *LokiClient) mcp.ToolHandler {
|
||||
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||
logql, _ := args["logql"].(string)
|
||||
if logql == "" {
|
||||
return mcp.ErrorContent(fmt.Errorf("logql is required")), nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
start := now.Add(-time.Hour)
|
||||
end := now
|
||||
|
||||
if startStr, ok := args["start"].(string); ok && startStr != "" {
|
||||
parsed, err := parseTimeArg(startStr, now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
return mcp.ErrorContent(fmt.Errorf("invalid start time: %w", err)), nil
|
||||
}
|
||||
start = parsed
|
||||
}
|
||||
|
||||
if endStr, ok := args["end"].(string); ok && endStr != "" {
|
||||
parsed, err := parseTimeArg(endStr, now)
|
||||
if err != nil {
|
||||
return mcp.ErrorContent(fmt.Errorf("invalid end time: %w", err)), nil
|
||||
}
|
||||
end = parsed
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
direction := "backward"
|
||||
if d, ok := args["direction"].(string); ok && d != "" {
|
||||
direction = d
|
||||
}
|
||||
|
||||
data, err := loki.QueryRange(ctx, logql, start, end, limit, direction)
|
||||
if err != nil {
|
||||
return mcp.ErrorContent(fmt.Errorf("log query failed: %w", err)), nil
|
||||
}
|
||||
|
||||
return mcp.CallToolResult{
|
||||
Content: []mcp.Content{mcp.TextContent(formatLogStreams(data))},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeListLabelsHandler(loki *LokiClient) mcp.ToolHandler {
|
||||
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||
labels, err := loki.Labels(ctx)
|
||||
if err != nil {
|
||||
return mcp.ErrorContent(fmt.Errorf("failed to list labels: %w", err)), nil
|
||||
}
|
||||
|
||||
return mcp.CallToolResult{
|
||||
Content: []mcp.Content{mcp.TextContent(formatLabels(labels))},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeListLabelValuesHandler(loki *LokiClient) mcp.ToolHandler {
|
||||
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||
label, _ := args["label"].(string)
|
||||
if label == "" {
|
||||
return mcp.ErrorContent(fmt.Errorf("label is required")), nil
|
||||
}
|
||||
|
||||
values, err := loki.LabelValues(ctx, label)
|
||||
if err != nil {
|
||||
return mcp.ErrorContent(fmt.Errorf("failed to list label values: %w", err)), nil
|
||||
}
|
||||
|
||||
return mcp.CallToolResult{
|
||||
Content: []mcp.Content{mcp.TextContent(formatLabelValues(label, values))},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseTimeArg parses a time argument that can be:
|
||||
// - A relative duration (e.g., "1h", "30m", "2h30m") — interpreted as that duration ago from now
|
||||
// - An RFC3339 timestamp (e.g., "2024-01-15T10:30:00Z")
|
||||
// - A Unix epoch in seconds (e.g., "1705312200")
|
||||
// If parsing fails, returns the provided default time.
|
||||
func parseTimeArg(s string, defaultTime time.Time) (time.Time, error) {
|
||||
// Try as relative duration first
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return time.Now().Add(-d), nil
|
||||
}
|
||||
|
||||
// Try as RFC3339
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Try as Unix epoch seconds
|
||||
var epoch int64
|
||||
validDigits := true
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
epoch = epoch*10 + int64(c-'0')
|
||||
} else {
|
||||
validDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if validDigits && len(s) > 0 {
|
||||
return time.Unix(epoch, 0), nil
|
||||
}
|
||||
|
||||
return defaultTime, fmt.Errorf("cannot parse time '%s': use relative duration (e.g., '1h'), RFC3339, or Unix epoch seconds", s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user