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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user