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:
2026-02-05 20:55:39 +01:00
parent f4f859fefa
commit 859e35ab5c
13 changed files with 1014 additions and 20 deletions

View File

@@ -294,6 +294,126 @@ func formatMetricSearch(names []string, metadata map[string][]PromMetadata) stri
return sb.String()
}
const maxLabelValues = 100
const maxLineLength = 500
// formatLogStreams formats Loki log query results as grouped markdown.
func formatLogStreams(data *LokiQueryData) string {
if data == nil || len(data.Result) == 0 {
return "No log results."
}
var sb strings.Builder
totalEntries := 0
for _, s := range data.Result {
totalEntries += len(s.Values)
}
sb.WriteString(fmt.Sprintf("**%d stream(s), %d total log entries**\n\n", len(data.Result), totalEntries))
for _, stream := range data.Result {
// Stream labels header
var labels []string
for k, v := range stream.Stream {
labels = append(labels, fmt.Sprintf("%s=%q", k, v))
}
sort.Strings(labels)
sb.WriteString(fmt.Sprintf("## {%s}\n\n", strings.Join(labels, ", ")))
if len(stream.Values) == 0 {
sb.WriteString("No entries.\n\n")
continue
}
sb.WriteString("| Timestamp | Log Line |\n")
sb.WriteString("| --- | --- |\n")
truncated := false
for i, entry := range stream.Values {
if i >= maxRows {
truncated = true
break
}
ts := formatNanosecondTimestamp(entry[0])
line := entry[1]
if len(line) > maxLineLength {
line = line[:maxLineLength] + "..."
}
// Escape pipe characters in log lines for markdown table
line = strings.ReplaceAll(line, "|", "\\|")
// Replace newlines with spaces for table compatibility
line = strings.ReplaceAll(line, "\n", " ")
sb.WriteString(fmt.Sprintf("| %s | %s |\n", ts, line))
}
if truncated {
sb.WriteString(fmt.Sprintf("\n*Showing %d of %d entries (truncated)*\n", maxRows, len(stream.Values)))
}
sb.WriteString("\n")
}
return sb.String()
}
// formatLabels formats a list of label names as a bullet list.
func formatLabels(labels []string) string {
if len(labels) == 0 {
return "No labels found."
}
sort.Strings(labels)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%d label(s)**\n\n", len(labels)))
for _, label := range labels {
sb.WriteString(fmt.Sprintf("- `%s`\n", label))
}
return sb.String()
}
// formatLabelValues formats label values as a bullet list.
func formatLabelValues(label string, values []string) string {
if len(values) == 0 {
return fmt.Sprintf("No values found for label '%s'.", label)
}
sort.Strings(values)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%d value(s) for label `%s`**\n\n", len(values), label))
truncated := false
for i, v := range values {
if i >= maxLabelValues {
truncated = true
break
}
sb.WriteString(fmt.Sprintf("- `%s`\n", v))
}
if truncated {
sb.WriteString(fmt.Sprintf("\n*Showing %d of %d values (truncated)*\n", maxLabelValues, len(values)))
}
return sb.String()
}
// formatNanosecondTimestamp converts a nanosecond Unix timestamp string to RFC3339.
func formatNanosecondTimestamp(nsStr string) string {
var ns int64
for _, c := range nsStr {
if c >= '0' && c <= '9' {
ns = ns*10 + int64(c-'0')
}
}
t := time.Unix(0, ns)
return t.UTC().Format(time.RFC3339)
}
// formatMetricMetadata formats metadata for a single metric.
func formatMetricMetadata(name string, metas []PromMetadata) string {
if len(metas) == 0 {