mirror of
https://github.com/hanzoai/o11y-mcp.git
synced 2026-10-30 18:26:37 +00:00
The module declared itself github.com/SigNoz/signoz-mcp-server and imported the
schema migrator from github.com/SigNoz/signoz-otel-collector — a fork that
hanzoai/otel-collector superseded, where the same package is
cmd/o11yschemamigrator/schema_migrator. Both now point at the repo that is
actually maintained, at v1.3.0.
Go identifiers came with it: the client type is Client rather than SigNoz, the
import alias is o11yclient, and the env-name constants are EnvURL and
EnvAPIKey.
NOT touched, because each is a contract with something running and renaming it
breaks that thing rather than debranding it:
the 30 MCP tool names (signoz_list_alerts, signoz_get_trace_details, …) and
the signoz://dashboard/* resource URIs, which are this server's public API
and are what an agent calls;
the env vars SIGNOZ_URL and SIGNOZ_API_KEY and the SIGNOZ-API-KEY header,
which the deployment sets;
the table names — signoz_traces.distributed_signoz_index_v3, signoz_logs.*,
signoz_metrics.* — and the metric names signoz_calls_total and
signoz_latency_*, which name rows and series that exist on the server.
Those want a coordinated change on both sides, not a rename here.
go build and go vet both clean.
158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/o11y-mcp/pkg/timeutil"
|
|
"github.com/hanzoai/o11y-mcp/pkg/types"
|
|
)
|
|
|
|
var validAggregations = map[string]bool{
|
|
"count": true,
|
|
"count_distinct": true,
|
|
"avg": true,
|
|
"sum": true,
|
|
"min": true,
|
|
"max": true,
|
|
"p50": true,
|
|
"p75": true,
|
|
"p90": true,
|
|
"p95": true,
|
|
"p99": true,
|
|
"rate": true,
|
|
}
|
|
|
|
var aggregationsWithoutField = map[string]bool{
|
|
"count": true,
|
|
"rate": true,
|
|
}
|
|
|
|
const allowedAggregations = "avg, count, count_distinct, max, min, p50, p75, p90, p95, p99, rate, sum"
|
|
|
|
// AggregateRequest keeps parameters for any aggregation query.
|
|
type AggregateRequest struct {
|
|
AggregationExpr string
|
|
FilterExpression string
|
|
GroupBy []types.SelectField
|
|
OrderExpr string
|
|
OrderDir string
|
|
Limit int
|
|
StartTime int64
|
|
EndTime int64
|
|
}
|
|
|
|
// parseAggregateArgs validates and parses aggregate arguments.
|
|
// this is crucial as the input is provided by llm and if there is an error it must be suggested how to correct
|
|
func parseAggregateArgs(args map[string]any, signal string, filterExpr string) (*AggregateRequest, error) {
|
|
aggregation, _ := args["aggregation"].(string)
|
|
if aggregation == "" {
|
|
return nil, fmt.Errorf(
|
|
"\"aggregation\" is required. Supported values: %s. "+
|
|
"Tip: for simple totals use {\"aggregation\": \"count\", \"groupBy\": \"service.name\"}",
|
|
allowedAggregations)
|
|
}
|
|
if !validAggregations[aggregation] {
|
|
return nil, fmt.Errorf(
|
|
"invalid aggregation %q. Supported values: %s. "+
|
|
"Tip: for counting use \"count\", for averages use \"avg\"",
|
|
aggregation, allowedAggregations)
|
|
}
|
|
|
|
aggregateOn, _ := args["aggregateOn"].(string)
|
|
if !aggregationsWithoutField[aggregation] && aggregateOn == "" {
|
|
return nil, fmt.Errorf(
|
|
"\"aggregateOn\" is required for %q aggregation. Specify the field to aggregate, "+
|
|
"e.g. {\"aggregation\": \"%s\", \"aggregateOn\": \"duration\"}",
|
|
aggregation, aggregation)
|
|
}
|
|
|
|
var aggregationExpr string
|
|
if aggregateOn != "" {
|
|
aggregationExpr = fmt.Sprintf("%s(%s)", aggregation, aggregateOn)
|
|
} else {
|
|
aggregationExpr = fmt.Sprintf("%s()", aggregation)
|
|
}
|
|
|
|
var groupByFields []types.SelectField
|
|
if groupByStr, _ := args["groupBy"].(string); groupByStr != "" {
|
|
for _, field := range strings.Split(groupByStr, ",") {
|
|
field = strings.TrimSpace(field)
|
|
if field != "" {
|
|
groupByFields = append(groupByFields, types.SelectField{Name: field, Signal: signal})
|
|
}
|
|
}
|
|
}
|
|
|
|
orderByRaw, _ := args["orderBy"].(string)
|
|
orderByStr := strings.TrimSpace(orderByRaw)
|
|
orderExpr, orderDir := aggregationExpr, "desc"
|
|
if orderByStr != "" {
|
|
lower := strings.ToLower(orderByStr)
|
|
switch {
|
|
case strings.HasSuffix(lower, " asc"):
|
|
orderExpr = strings.TrimSpace(orderByStr[:len(orderByStr)-4])
|
|
orderDir = "asc"
|
|
case strings.HasSuffix(lower, " desc"):
|
|
orderExpr = strings.TrimSpace(orderByStr[:len(orderByStr)-5])
|
|
default:
|
|
orderExpr = orderByStr
|
|
}
|
|
}
|
|
|
|
limit, err := intArg(args, "limit", 10)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
startTime, endTime, err := resolveTimestamps(args, "1h")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &AggregateRequest{
|
|
AggregationExpr: aggregationExpr,
|
|
FilterExpression: filterExpr,
|
|
GroupBy: groupByFields,
|
|
OrderExpr: orderExpr,
|
|
OrderDir: orderDir,
|
|
Limit: limit,
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
}, nil
|
|
}
|
|
|
|
func resolveTimestamps(args map[string]any, defaultRange string) (int64, int64, error) {
|
|
if _, ok := args["timeRange"]; !ok {
|
|
if _, ok := args["start"]; !ok {
|
|
args["timeRange"] = defaultRange
|
|
}
|
|
}
|
|
start, end := timeutil.GetTimestampsWithDefaults(args, "ms")
|
|
var startTime, endTime int64
|
|
if err := json.Unmarshal([]byte(start), &startTime); err != nil {
|
|
return 0, 0, fmt.Errorf("invalid start timestamp: use timeRange instead (e.g., \"1h\", \"24h\")")
|
|
}
|
|
if err := json.Unmarshal([]byte(end), &endTime); err != nil {
|
|
return 0, 0, fmt.Errorf("invalid end timestamp: use timeRange instead (e.g., \"1h\", \"24h\")")
|
|
}
|
|
return startTime, endTime, nil
|
|
}
|
|
|
|
func intArg(args map[string]any, key string, defaultVal int) (int, error) {
|
|
str, _ := args[key].(string)
|
|
if str == "" {
|
|
return defaultVal, nil
|
|
}
|
|
num, err := strconv.Atoi(str)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %q value %q: must be a number", key, str)
|
|
}
|
|
if num <= 0 {
|
|
return defaultVal, nil
|
|
}
|
|
return num, nil
|
|
}
|