mirror of https://github.com/linkerd/linkerd2.git
364 lines
10 KiB
Go
364 lines
10 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/linkerd/linkerd2/controller/api/util"
|
|
pb "github.com/linkerd/linkerd2/controller/gen/public"
|
|
"github.com/linkerd/linkerd2/pkg/k8s"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type routesOptions struct {
|
|
statOptionsBase
|
|
toResource string
|
|
toNamespace string
|
|
dstIsService bool
|
|
labelSelector string
|
|
}
|
|
|
|
type routeRowStats struct {
|
|
rowStats
|
|
actualRequestRate float64
|
|
actualSuccessRate float64
|
|
hasRequestData bool
|
|
}
|
|
|
|
func newRoutesOptions() *routesOptions {
|
|
return &routesOptions{
|
|
statOptionsBase: *newStatOptionsBase(),
|
|
toResource: "",
|
|
toNamespace: "",
|
|
labelSelector: "",
|
|
}
|
|
}
|
|
|
|
func newCmdRoutes() *cobra.Command {
|
|
options := newRoutesOptions()
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "routes [flags] (RESOURCES)",
|
|
Short: "Display route stats",
|
|
Long: `Display route stats.
|
|
|
|
This command will only display traffic which is sent to a service that has a Service Profile defined.`,
|
|
Example: ` # Routes for the webapp service in the test namespace.
|
|
linkerd routes service/webapp -n test
|
|
|
|
# Routes for calls from the traffic deployment to the webapp service in the test namespace.
|
|
linkerd routes deploy/traffic -n test --to svc/webapp`,
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgs: util.ValidTargets,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
req, err := buildTopRoutesRequest(args[0], options)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating metrics request while making routes request: %v", err)
|
|
}
|
|
|
|
output, err := requestRouteStatsFromAPI(checkPublicAPIClientOrExit(), req, options)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = fmt.Print(output)
|
|
|
|
return err
|
|
},
|
|
}
|
|
|
|
cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace, "Namespace of the specified resource")
|
|
cmd.PersistentFlags().StringVarP(&options.timeWindow, "time-window", "t", options.timeWindow, "Stat window (for example: \"10s\", \"1m\", \"10m\", \"1h\")")
|
|
cmd.PersistentFlags().StringVar(&options.toResource, "to", options.toResource, "If present, shows outbound stats to the specified resource")
|
|
cmd.PersistentFlags().StringVar(&options.toNamespace, "to-namespace", options.toNamespace, "Sets the namespace used to lookup the \"--to\" resource; by default the current \"--namespace\" is used")
|
|
cmd.PersistentFlags().StringVarP(&options.outputFormat, "output", "o", options.outputFormat, fmt.Sprintf("Output format; one of: \"%s\", \"%s\", or \"%s\"", tableOutput, wideOutput, jsonOutput))
|
|
cmd.PersistentFlags().StringVarP(&options.labelSelector, "selector", "l", options.labelSelector, "Selector (label query) to filter on, supports '=', '==', and '!='")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func requestRouteStatsFromAPI(client pb.ApiClient, req *pb.TopRoutesRequest, options *routesOptions) (string, error) {
|
|
resp, err := client.TopRoutes(context.Background(), req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("TopRoutes API error: %v", err)
|
|
}
|
|
if e := resp.GetError(); e != nil {
|
|
return "", errors.New(e.Error)
|
|
}
|
|
|
|
return renderRouteStats(resp, options), nil
|
|
}
|
|
|
|
func renderRouteStats(resp *pb.TopRoutesResponse, options *routesOptions) string {
|
|
var buffer bytes.Buffer
|
|
w := tabwriter.NewWriter(&buffer, 0, 0, padding, ' ', tabwriter.AlignRight)
|
|
writeRouteStatsToBuffer(resp, w, options)
|
|
w.Flush()
|
|
|
|
return renderStats(buffer, &options.statOptionsBase)
|
|
}
|
|
|
|
func writeRouteStatsToBuffer(resp *pb.TopRoutesResponse, w *tabwriter.Writer, options *routesOptions) {
|
|
|
|
tables := make(map[string][]*routeRowStats)
|
|
|
|
for _, resourceTable := range resp.GetOk().GetRoutes() {
|
|
|
|
table := make([]*routeRowStats, 0)
|
|
|
|
for _, r := range resourceTable.GetRows() {
|
|
if r.Stats != nil {
|
|
route := r.GetRoute()
|
|
table = append(table, &routeRowStats{
|
|
rowStats: rowStats{
|
|
route: route,
|
|
dst: r.GetAuthority(),
|
|
requestRate: getRequestRate(r.Stats.GetSuccessCount(), r.Stats.GetFailureCount(), r.TimeWindow),
|
|
successRate: getSuccessRate(r.Stats.GetSuccessCount(), r.Stats.GetFailureCount()),
|
|
latencyP50: r.Stats.LatencyMsP50,
|
|
latencyP95: r.Stats.LatencyMsP95,
|
|
latencyP99: r.Stats.LatencyMsP99,
|
|
},
|
|
actualRequestRate: getRequestRate(r.Stats.GetActualSuccessCount(), r.Stats.GetActualFailureCount(), r.TimeWindow),
|
|
actualSuccessRate: getSuccessRate(r.Stats.GetActualSuccessCount(), r.Stats.GetActualFailureCount()),
|
|
hasRequestData: statHasRequestData(r.Stats),
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(table, func(i, j int) bool {
|
|
return table[i].dst+table[i].route < table[j].dst+table[j].route
|
|
})
|
|
|
|
tables[resourceTable.GetResource()] = table
|
|
}
|
|
|
|
resources := make([]string, 0)
|
|
for resource := range tables {
|
|
resources = append(resources, resource)
|
|
}
|
|
sort.Strings(resources)
|
|
|
|
switch options.outputFormat {
|
|
case tableOutput, wideOutput:
|
|
for _, resource := range resources {
|
|
if len(tables) > 1 {
|
|
fmt.Fprintf(w, "==> %s <==\t\f", resource)
|
|
}
|
|
printRouteTable(tables[resource], w, options)
|
|
fmt.Fprintln(w)
|
|
}
|
|
case jsonOutput:
|
|
printRouteJSON(tables, w, options)
|
|
}
|
|
}
|
|
|
|
func printRouteTable(stats []*routeRowStats, w *tabwriter.Writer, options *routesOptions) {
|
|
// template for left-aligning the route column
|
|
routeTemplate := fmt.Sprintf("%%-%ds", routeWidth(stats))
|
|
|
|
authorityColumn := "AUTHORITY"
|
|
if options.dstIsService {
|
|
authorityColumn = "SERVICE"
|
|
}
|
|
|
|
headers := []string{
|
|
fmt.Sprintf(routeTemplate, "ROUTE"),
|
|
authorityColumn,
|
|
}
|
|
outputActual := options.toResource != "" && options.outputFormat == wideOutput
|
|
if outputActual {
|
|
headers = append(headers, []string{
|
|
"EFFECTIVE_SUCCESS",
|
|
"EFFECTIVE_RPS",
|
|
"ACTUAL_SUCCESS",
|
|
"ACTUAL_RPS",
|
|
}...)
|
|
} else {
|
|
headers = append(headers, []string{
|
|
"SUCCESS",
|
|
"RPS",
|
|
}...)
|
|
}
|
|
|
|
headers = append(headers, []string{
|
|
"LATENCY_P50",
|
|
"LATENCY_P95",
|
|
"LATENCY_P99\t", // trailing \t is required to format last column
|
|
}...)
|
|
|
|
fmt.Fprintln(w, strings.Join(headers, "\t"))
|
|
|
|
// route, success rate, rps
|
|
templateString := routeTemplate + "\t%s\t%.2f%%\t%.1frps\t"
|
|
if outputActual {
|
|
// actual success rate, actual rps
|
|
templateString = templateString + "%.2f%%\t%.1frps\t"
|
|
}
|
|
// p50, p95, p99
|
|
templateString = templateString + "%dms\t%dms\t%dms\t\n"
|
|
|
|
var emptyTemplateString string
|
|
if outputActual {
|
|
emptyTemplateString = routeTemplate + "\t%s\t-\t-\t-\t-\t-\t-\t-\t\n"
|
|
} else {
|
|
emptyTemplateString = routeTemplate + "\t%s\t-\t-\t-\t-\t-\t\n"
|
|
}
|
|
|
|
for _, row := range stats {
|
|
|
|
values := []interface{}{
|
|
row.route,
|
|
row.dst,
|
|
}
|
|
|
|
if row.hasRequestData {
|
|
values = append(values, []interface{}{
|
|
row.successRate * 100,
|
|
row.requestRate,
|
|
}...)
|
|
|
|
if outputActual {
|
|
values = append(values, []interface{}{
|
|
row.actualSuccessRate * 100,
|
|
row.actualRequestRate,
|
|
}...)
|
|
}
|
|
values = append(values, []interface{}{
|
|
row.latencyP50,
|
|
row.latencyP95,
|
|
row.latencyP99,
|
|
}...)
|
|
|
|
fmt.Fprintf(w, templateString, values...)
|
|
} else {
|
|
fmt.Fprintf(w, emptyTemplateString, values...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// JSONRouteStats represents the JSON output of the routes command
|
|
// Using pointers there where the value is NA and the corresponding json is null
|
|
type JSONRouteStats struct {
|
|
Route string `json:"route"`
|
|
Authority string `json:"authority"`
|
|
Success *float64 `json:"success,omitempty"`
|
|
Rps *float64 `json:"rps,omitempty"`
|
|
EffectiveSuccess *float64 `json:"effective_success,omitempty"`
|
|
EffectiveRps *float64 `json:"effective_rps,omitempty"`
|
|
ActualSuccess *float64 `json:"actual_success,omitempty"`
|
|
ActualRps *float64 `json:"actual_rps,omitempty"`
|
|
LatencyMSp50 *uint64 `json:"latency_ms_p50"`
|
|
LatencyMSp95 *uint64 `json:"latency_ms_p95"`
|
|
LatencyMSp99 *uint64 `json:"latency_ms_p99"`
|
|
}
|
|
|
|
func printRouteJSON(tables map[string][]*routeRowStats, w *tabwriter.Writer, options *routesOptions) {
|
|
// avoid nil initialization so that if there are not stats it gets marshalled as an empty array vs null
|
|
entries := map[string][]*JSONRouteStats{}
|
|
for resource, table := range tables {
|
|
for _, row := range table {
|
|
route := row.route
|
|
entry := &JSONRouteStats{
|
|
Route: route,
|
|
}
|
|
|
|
entry.Authority = row.dst
|
|
if options.toResource != "" {
|
|
entry.EffectiveSuccess = &row.successRate
|
|
entry.EffectiveRps = &row.requestRate
|
|
entry.ActualSuccess = &row.actualSuccessRate
|
|
entry.ActualRps = &row.actualRequestRate
|
|
} else {
|
|
entry.Success = &row.successRate
|
|
entry.Rps = &row.requestRate
|
|
}
|
|
entry.LatencyMSp50 = &row.latencyP50
|
|
entry.LatencyMSp95 = &row.latencyP95
|
|
entry.LatencyMSp99 = &row.latencyP99
|
|
|
|
entries[resource] = append(entries[resource], entry)
|
|
}
|
|
}
|
|
b, err := json.MarshalIndent(entries, "", " ")
|
|
if err != nil {
|
|
log.Error(err.Error())
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "%s\n", b)
|
|
}
|
|
|
|
func (o *routesOptions) validateOutputFormat() error {
|
|
switch o.outputFormat {
|
|
case tableOutput, jsonOutput:
|
|
return nil
|
|
case wideOutput:
|
|
if o.toResource == "" {
|
|
return fmt.Errorf("%s output is only available when --to is specified", wideOutput)
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("--output currently only supports %s, %s, and %s", tableOutput, wideOutput, jsonOutput)
|
|
}
|
|
}
|
|
|
|
func buildTopRoutesRequest(resource string, options *routesOptions) (*pb.TopRoutesRequest, error) {
|
|
err := options.validateOutputFormat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
target, err := util.BuildResource(options.namespace, resource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestParams := util.TopRoutesRequestParams{
|
|
StatsBaseRequestParams: util.StatsBaseRequestParams{
|
|
TimeWindow: options.timeWindow,
|
|
ResourceName: target.Name,
|
|
ResourceType: target.Type,
|
|
Namespace: options.namespace,
|
|
},
|
|
LabelSelector: options.labelSelector,
|
|
}
|
|
|
|
options.dstIsService = !(target.GetType() == k8s.Authority)
|
|
|
|
if options.toResource != "" {
|
|
if options.toNamespace == "" {
|
|
options.toNamespace = options.namespace
|
|
}
|
|
toRes, err := util.BuildResource(options.toNamespace, options.toResource)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
options.dstIsService = !(toRes.GetType() == k8s.Authority)
|
|
|
|
requestParams.ToName = toRes.Name
|
|
requestParams.ToNamespace = toRes.Namespace
|
|
requestParams.ToType = toRes.Type
|
|
}
|
|
|
|
return util.BuildTopRoutesRequest(requestParams)
|
|
}
|
|
|
|
// returns the length of the longest route name
|
|
func routeWidth(stats []*routeRowStats) int {
|
|
maxLength := 0
|
|
for _, row := range stats {
|
|
if len(row.route) > maxLength {
|
|
maxLength = len(row.route)
|
|
}
|
|
}
|
|
return maxLength
|
|
}
|