Append links directly to metrics

The initial idea was to keep it separate since the unattached
links were also to be displayed distinctively from the metrics.
With the new design, unattached links are rendered in the same
list as metrics with attached links.

Therefore, we treat unattached metric links as an empty metric.
This commit is contained in:
Roland Schilter
2017-07-19 11:01:47 +02:00
parent b8c75071fe
commit 8188b7aed2
23 changed files with 518 additions and 290 deletions

View File

@@ -12,46 +12,70 @@ import (
"github.com/ugorji/go/codec"
)
// MetricLink describes a URL referencing a metric.
type MetricLink struct {
// References the metric id
ID string `json:"id"`
Label string `json:"label"`
URL string `json:"url"`
Priority int `json:"priority"`
}
// Variable name for the query within the metrics graph url
// Replacement variable name for the query in the metrics graph url
const urlQueryVarName = ":query"
var (
// Available metric links
linkTemplates = []MetricLink{
{ID: docker.CPUTotalUsage, Label: "CPU", Priority: 1},
{ID: docker.MemoryUsage, Label: "Memory", Priority: 2},
// Metadata for shown queries
shownQueries = []struct {
ID string
Label string
}{
{
ID: docker.CPUTotalUsage,
Label: "CPU",
},
{
ID: docker.MemoryUsage,
Label: "Memory",
},
{
ID: "receive_bytes",
Label: "Rx/s",
},
{
ID: "transmit_bytes",
Label: "Tx/s",
},
}
// Prometheus queries for topologies
//
// Metrics
// - `container_cpu_usage_seconds_total` --> cAdvisor in Kubelets.
// - `container_memory_usage_bytes` --> cAdvisor in Kubelets.
topologyQueries = map[string]map[string]*template.Template{
report.Pod: {
// `container_memory_usage_bytes` is provided by cAdvisor in Kubelets.
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name="{{.Label}}"})`),
// `container_cpu_usage_seconds_total` is provided by cAdvisor in Kubelets.
// Containers
report.Container: {
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{container_name="{{.Label}}"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{container_name="{{.Label}}"}[1m]))`),
},
report.ContainerImage: {
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{image="{{.Label}}"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{image="{{.Label}}"}[1m]))`),
},
"group:container:docker_container_hostname": {
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name="{{.Label}}"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name="{{.Label}}"}[1m]))`),
},
report.Deployment: {
// `container_memory_usage_bytes` is provided by cAdvisor in Kubelets.
// Pod names are automatically generated by k8s using the deployment name:
// https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#pod-template-hash-label
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name=~"^{{.Label}}-[^-]+-[^-]+$"})`),
// `container_cpu_usage_seconds_total` is provided by cAdvisor in Kubelets.
// Kubernetes topologies
report.Pod: {
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name="{{.Label}}"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name="{{.Label}}"}[1m]))`),
"receive_bytes": parsedTemplate(`sum(rate(container_network_receive_bytes_total{pod_name="{{.Label}}"}[5m]))`),
"transmit_bytes": parsedTemplate(`sum(rate(container_network_transmit_bytes_total{pod_name="{{.Label}}"}[5m]))`),
},
// Pod naming:
// https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#pod-template-hash-label
"__k8s_controllers": {
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name=~"^{{.Label}}-[^-]+-[^-]+$"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name=~"^{{.Label}}-[^-]+-[^-]+$"}[1m]))`),
},
report.DaemonSet: {
// `container_memory_usage_bytes` is provided by cAdvisor in Kubelets.
// Pod names are automatically generated by k8s using the DaemonSet name.
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name=~"^{{.Label}}-[^-]+$"})`),
// `container_cpu_usage_seconds_total` is provided by cAdvisor in Kubelets.
docker.MemoryUsage: parsedTemplate(`sum(container_memory_usage_bytes{pod_name=~"^{{.Label}}-[^-]+$"})`),
docker.CPUTotalUsage: parsedTemplate(`sum(rate(container_cpu_usage_seconds_total{pod_name=~"^{{.Label}}-[^-]+$"}[1m]))`),
},
report.Service: {
@@ -59,45 +83,40 @@ var (
// NB: Pods need to be labeled and selected by their respective Service name, meaning:
// - The Service's `spec.selector` needs to select on `name`
// - The Service's `metadata.name` needs to be the same value as `spec.selector.name`
docker.MemoryUsage: parsedTemplate(`namespace_label_name:container_memory_usage_bytes:sum{label_name="{{.Label}}"}`),
docker.MemoryUsage: parsedTemplate(`namespace_label_name:container_memory_usage_bytes:sum{label_name="{{.Label}}"}`),
docker.CPUTotalUsage: parsedTemplate(`namespace_label_name:container_cpu_usage_seconds_total:sum_rate{label_name="{{.Label}}}`),
},
}
k8sControllers = map[string]struct{}{
report.Deployment: {},
"stateful_set": {},
"cron_job": {},
}
)
// NodeMetricLinks returns the links of a node. The links are collected
// by a predefined set but filtered depending on whether a query
// is configured or not for the particular topology.
func NodeMetricLinks(_ report.Report, n report.Node) []MetricLink {
queries := topologyQueries[n.Topology]
if len(queries) == 0 {
return nil
}
links := []MetricLink{}
for _, link := range linkTemplates {
if _, ok := queries[link.ID]; ok {
links = append(links, link)
}
}
return links
}
// RenderMetricLinks executes the templated links by supplying the node summary as data.
// `metricsGraphURL` supports placeholders such as `:orgID` and `:query`. If the `:query`
// part is missing, a JSON version will be appended, see `queryParamsAsJSON()` for more info.
// It returns the modified summary.
func RenderMetricLinks(summary NodeSummary, n report.Node, metricsGraphURL string) NodeSummary {
queries := topologyQueries[n.Topology]
if len(queries) == 0 || len(summary.MetricLinks) == 0 {
// RenderMetricURLs sets respective URLs for metrics in a node summary. Missing metrics
// where we have a query for will be appended as an empty metric (no values or samples).
func RenderMetricURLs(summary NodeSummary, n report.Node, metricsGraphURL string) NodeSummary {
if metricsGraphURL == "" {
return summary
}
links := []MetricLink{}
queries := getTopologyQueries(n.Topology)
if len(queries) == 0 {
return summary
}
var maxprio float64
var bs bytes.Buffer
for _, link := range summary.MetricLinks {
tpl := queries[link.ID]
var ms []report.MetricRow
found := make(map[string]struct{})
// Set URL on existing metrics
for _, metric := range summary.Metrics {
if metric.Priority > maxprio {
maxprio = metric.Priority
}
tpl := queries[metric.ID]
if tpl == nil {
continue
}
@@ -107,19 +126,47 @@ func RenderMetricLinks(summary NodeSummary, n report.Node, metricsGraphURL strin
continue
}
link.URL = buildURL(bs.String(), metricsGraphURL)
links = append(links, link)
ms = append(ms, metric)
ms[len(ms)-1].URL = buildURL(bs.String(), metricsGraphURL)
found[metric.ID] = struct{}{}
}
summary.MetricLinks = links
// Append empty metrics for unattached queries
for _, metadata := range shownQueries {
if _, ok := found[metadata.ID]; ok {
continue
}
tpl := queries[metadata.ID]
if tpl == nil {
continue
}
bs.Reset()
if err := tpl.Execute(&bs, summary); err != nil {
continue
}
maxprio++
ms = append(ms, report.MetricRow{
ID: metadata.ID,
Label: metadata.Label,
URL: buildURL(bs.String(), metricsGraphURL),
Metric: &report.Metric{},
Priority: maxprio,
ValueEmpty: true,
})
}
summary.Metrics = ms
return summary
}
// buildURL puts together the URL by looking at the configured
// `metricsGraphURL`.
// buildURL puts together the URL by looking at the configured `metricsGraphURL`.
func buildURL(query, metricsGraphURL string) string {
if strings.Contains(metricsGraphURL, urlQueryVarName) {
return strings.Replace(metricsGraphURL, urlQueryVarName, url.PathEscape(query), -1)
return strings.Replace(metricsGraphURL, urlQueryVarName, url.QueryEscape(query), -1)
}
params, err := queryParamsAsJSON(query)
@@ -131,7 +178,7 @@ func buildURL(query, metricsGraphURL string) string {
metricsGraphURL += "/"
}
return metricsGraphURL + url.PathEscape(params)
return metricsGraphURL + url.QueryEscape(params)
}
// queryParamsAsJSON packs the query into a JSON of the
@@ -163,3 +210,10 @@ func parsedTemplate(query string) *template.Template {
return tpl
}
func getTopologyQueries(t string) map[string]*template.Template {
if _, ok := k8sControllers[t]; ok {
t = "__k8s_controllers"
}
return topologyQueries[t]
}

View File

@@ -10,54 +10,82 @@ import (
"github.com/stretchr/testify/assert"
)
var (
sampleReport = report.Report{}
samplePodNode = report.MakeNode("noo").WithTopology(report.Pod)
sampleUnknownNode = report.MakeNode("???").WithTopology("foo")
const (
sampleMetricsGraphURL = "/prom/:orgID/notebook/new"
)
func TestNodeMetricLinks_UnknownTopology(t *testing.T) {
links := detailed.NodeMetricLinks(sampleReport, sampleUnknownNode)
assert.Nil(t, links)
}
func TestNodeMetricLinks(t *testing.T) {
expected := []detailed.MetricLink{
{ID: docker.CPUTotalUsage, Label: "CPU", Priority: 1, URL: ""},
{ID: docker.MemoryUsage, Label: "Memory", Priority: 2, URL: ""},
var (
sampleUnknownNode = report.MakeNode("???").WithTopology("foo")
samplePodNode = report.MakeNode("noo").WithTopology(report.Pod)
sampleMetrics = []report.MetricRow{
{ID: docker.MemoryUsage},
{ID: docker.CPUTotalUsage},
}
)
links := detailed.NodeMetricLinks(sampleReport, samplePodNode)
assert.Equal(t, expected, links)
func TestRenderMetricURLs_Disabled(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, "")
assert.Empty(t, result.Metrics[0].URL)
assert.Empty(t, result.Metrics[1].URL)
}
func TestRenderMetricLinks_UnknownTopology(t *testing.T) {
summary := detailed.NodeSummary{}
func TestRenderMetricURLs_UnknownTopology(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, sampleUnknownNode, sampleMetricsGraphURL)
result := detailed.RenderMetricLinks(summary, sampleUnknownNode, "")
assert.Equal(t, summary, result)
assert.Empty(t, result.Metrics[0].URL)
assert.Empty(t, result.Metrics[1].URL)
}
func TestRenderMetricLinks_Pod(t *testing.T) {
summary := detailed.NodeSummary{Label: "woo", MetricLinks: detailed.NodeMetricLinks(sampleReport, samplePodNode)}
func TestRenderMetricURLs(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, sampleMetricsGraphURL)
result := detailed.RenderMetricLinks(summary, samplePodNode, "/prom/:orgID/notebook/new")
assert.Equal(t,
"/prom/:orgID/notebook/new/%7B%22cells%22:%5B%7B%22queries%22:%5B%22sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name=%5C%22woo%5C%22%7D%5B1m%5D%29%29%22%5D%7D%5D%7D",
result.MetricLinks[0].URL)
assert.Equal(t,
"/prom/:orgID/notebook/new/%7B%22cells%22:%5B%7B%22queries%22:%5B%22sum%28container_memory_usage_bytes%7Bpod_name=%5C%22woo%5C%22%7D%29%22%5D%7D%5D%7D",
result.MetricLinks[1].URL)
u := "/prom/:orgID/notebook/new/%7B%22cells%22%3A%5B%7B%22queries%22%3A%5B%22sum%28container_memory_usage_bytes%7Bpod_name%3D%5C%22foo%5C%22%7D%29%22%5D%7D%5D%7D"
assert.Equal(t, u, result.Metrics[0].URL)
u = "/prom/:orgID/notebook/new/%7B%22cells%22%3A%5B%7B%22queries%22%3A%5B%22sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name%3D%5C%22foo%5C%22%7D%5B1m%5D%29%29%22%5D%7D%5D%7D"
assert.Equal(t, u, result.Metrics[1].URL)
}
func TestRenderMetricLinks_QueryReplacement(t *testing.T) {
summary := detailed.NodeSummary{Label: "boo", MetricLinks: detailed.NodeMetricLinks(sampleReport, samplePodNode)}
func TestRenderMetricURLs_EmptyMetrics(t *testing.T) {
result := detailed.RenderMetricURLs(detailed.NodeSummary{}, samplePodNode, sampleMetricsGraphURL)
result := detailed.RenderMetricLinks(summary, samplePodNode, "/foo/:orgID/bar?q=:query")
assert.Equal(t,
"/foo/:orgID/bar?q=sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name=%22boo%22%7D%5B1m%5D%29%29",
result.MetricLinks[0].URL)
assert.Equal(t,
"/foo/:orgID/bar?q=sum%28container_memory_usage_bytes%7Bpod_name=%22boo%22%7D%29",
result.MetricLinks[1].URL)
m := result.Metrics[0]
assert.Equal(t, docker.CPUTotalUsage, m.ID)
assert.Equal(t, "CPU", m.Label)
assert.NotEmpty(t, m.URL)
assert.True(t, m.ValueEmpty)
assert.Equal(t, float64(1), m.Priority)
m = result.Metrics[1]
assert.NotEmpty(t, m.URL)
assert.True(t, m.ValueEmpty)
assert.Equal(t, float64(2), m.Priority)
}
func TestRenderMetricURLs_CombinedEmptyMetrics(t *testing.T) {
s := detailed.NodeSummary{
Label: "foo",
Metrics: []report.MetricRow{{ID: docker.MemoryUsage, Priority: 1}},
}
result := detailed.RenderMetricURLs(s, samplePodNode, sampleMetricsGraphURL)
assert.NotEmpty(t, result.Metrics[0].URL)
assert.False(t, result.Metrics[0].ValueEmpty)
assert.NotEmpty(t, result.Metrics[1].URL)
assert.True(t, result.Metrics[1].ValueEmpty)
assert.Equal(t, float64(2), result.Metrics[1].Priority) // first empty metric starts at non-empty prio + 1
}
func TestRenderMetricURLs_QueryReplacement(t *testing.T) {
s := detailed.NodeSummary{Label: "foo", Metrics: sampleMetrics}
result := detailed.RenderMetricURLs(s, samplePodNode, "http://example.test/?q=:query")
u := "http://example.test/?q=sum%28container_memory_usage_bytes%7Bpod_name%3D%22foo%22%7D%29"
assert.Equal(t, u, result.Metrics[0].URL)
u = "http://example.test/?q=sum%28rate%28container_cpu_usage_seconds_total%7Bpod_name%3D%22foo%22%7D%5B1m%5D%29%29"
assert.Equal(t, u, result.Metrics[1].URL)
}

View File

@@ -44,20 +44,19 @@ type Column struct {
// NodeSummary is summary information about a child for a Node.
type NodeSummary struct {
ID string `json:"id"`
Label string `json:"label"`
LabelMinor string `json:"labelMinor"`
Rank string `json:"rank"`
Shape string `json:"shape,omitempty"`
Stack bool `json:"stack,omitempty"`
Linkable bool `json:"linkable,omitempty"` // Whether this node can be linked-to
Pseudo bool `json:"pseudo,omitempty"`
Metadata []report.MetadataRow `json:"metadata,omitempty"`
Parents []Parent `json:"parents,omitempty"`
Metrics []report.MetricRow `json:"metrics,omitempty"`
Tables []report.Table `json:"tables,omitempty"`
Adjacency report.IDList `json:"adjacency,omitempty"`
MetricLinks []MetricLink `json:"metric_links,omitempty"`
ID string `json:"id"`
Label string `json:"label"`
LabelMinor string `json:"labelMinor"`
Rank string `json:"rank"`
Shape string `json:"shape,omitempty"`
Stack bool `json:"stack,omitempty"`
Linkable bool `json:"linkable,omitempty"` // Whether this node can be linked-to
Pseudo bool `json:"pseudo,omitempty"`
Metadata []report.MetadataRow `json:"metadata,omitempty"`
Parents []Parent `json:"parents,omitempty"`
Metrics []report.MetricRow `json:"metrics,omitempty"`
Tables []report.Table `json:"tables,omitempty"`
Adjacency report.IDList `json:"adjacency,omitempty"`
}
var renderers = map[string]func(NodeSummary, report.Node) (NodeSummary, bool){
@@ -104,20 +103,20 @@ var primaryAPITopology = map[string]string{
// MakeNodeSummary summarizes a node, if possible.
func MakeNodeSummary(r report.Report, n report.Node, metricsGraphURL string) (NodeSummary, bool) {
metricLinks := metricsGraphURL != ""
if renderer, ok := renderers[n.Topology]; ok {
// Skip (and don't fall through to fallback) if renderer maps to nil
if renderer != nil {
summary, b := renderer(baseNodeSummary(r, n, metricLinks), n)
return RenderMetricLinks(summary, n, metricsGraphURL), b
summary, b := renderer(baseNodeSummary(r, n), n)
return RenderMetricURLs(summary, n, metricsGraphURL), b
}
} else if _, ok := r.Topology(n.Topology); ok {
summary := baseNodeSummary(r, n, metricLinks)
summary := baseNodeSummary(r, n)
summary.Label = n.ID // This is unlikely to look very good, but is a reasonable fallback
return summary, true
}
if strings.HasPrefix(n.Topology, "group:") {
return groupNodeSummary(baseNodeSummary(r, n, metricLinks), r, n)
summary, b := groupNodeSummary(baseNodeSummary(r, n), r, n)
return RenderMetricURLs(summary, n, metricsGraphURL), b
}
return NodeSummary{}, false
}
@@ -133,7 +132,7 @@ func (n NodeSummary) SummarizeMetrics() NodeSummary {
return n
}
func baseNodeSummary(r report.Report, n report.Node, metricLinks bool) NodeSummary {
func baseNodeSummary(r report.Report, n report.Node) NodeSummary {
t, _ := r.Topology(n.Topology)
ns := NodeSummary{
ID: n.ID,
@@ -145,9 +144,7 @@ func baseNodeSummary(r report.Report, n report.Node, metricLinks bool) NodeSumma
Tables: NodeTables(r, n),
Adjacency: n.Adjacency,
}
if metricLinks {
ns.MetricLinks = NodeMetricLinks(r, n)
}
return ns
}