This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package metriccatalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/itworx/pulse/internal/datasource"
|
||||
)
|
||||
|
||||
//go:embed seed.json
|
||||
var seedFS embed.FS
|
||||
|
||||
const (
|
||||
SchemaVersion = 1
|
||||
maxCatalogBytes = 4 << 20
|
||||
)
|
||||
|
||||
var semanticNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$`)
|
||||
var labelPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
|
||||
var allowedPlaceholders = map[string]struct{}{"instance": {}, "window": {}, "container": {}, "device": {}, "pool": {}, "probe_id": {}}
|
||||
|
||||
// Definition is the validated wire-compatible semantic metric definition.
|
||||
type Definition struct {
|
||||
SemanticName string `json:"semanticName"`
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Unit string `json:"unit"`
|
||||
ValueKind string `json:"valueKind"`
|
||||
SourceKind string `json:"sourceKind"`
|
||||
QueryTemplate string `json:"queryTemplate"`
|
||||
RequiredCapabilities []string `json:"requiredCapabilities,omitempty"`
|
||||
AllowedLabels []string `json:"allowedLabels"`
|
||||
DefaultAggregation string `json:"defaultAggregation,omitempty"`
|
||||
CardinalityBudget int `json:"cardinalityBudget"`
|
||||
Limits Limits `json:"limits"`
|
||||
AllowedVisualizations []string `json:"allowedVisualizations"`
|
||||
FreshnessSeconds int `json:"freshnessSeconds"`
|
||||
DefaultThresholds []Threshold `json:"defaultThresholds,omitempty"`
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
MaxRangeSeconds int `json:"maxRangeSeconds"`
|
||||
MaxSeries int `json:"maxSeries"`
|
||||
MaxPoints int `json:"maxPoints"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
type Threshold struct {
|
||||
State string `json:"state"`
|
||||
Operator string `json:"operator"`
|
||||
Value float64 `json:"value"`
|
||||
DurationSeconds int `json:"durationSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Metrics []Definition `json:"metrics"`
|
||||
}
|
||||
|
||||
type BindingState string
|
||||
|
||||
const (
|
||||
BindingSupported BindingState = "supported"
|
||||
BindingUnsupported BindingState = "unsupported"
|
||||
BindingUnavailable BindingState = "unavailable"
|
||||
)
|
||||
|
||||
type Binding struct {
|
||||
SemanticName string `json:"semanticName"`
|
||||
Version int `json:"version"`
|
||||
State BindingState `json:"state"`
|
||||
MissingCapabilities []string `json:"missingCapabilities,omitempty"`
|
||||
UnavailableCapabilities []string `json:"unavailableCapabilities,omitempty"`
|
||||
UnsupportedCapabilities []string `json:"unsupportedCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Version string `json:"version"`
|
||||
Metrics []Definition `json:"metrics"`
|
||||
Bindings []Binding `json:"bindings"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
catalog Catalog
|
||||
version string
|
||||
capabilities datasource.CapabilitySet
|
||||
}
|
||||
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
data, err := seedFS.ReadFile("seed.json")
|
||||
if err != nil {
|
||||
return Registry{}, fmt.Errorf("read embedded metric seed: %w", err)
|
||||
}
|
||||
return Load(context.Background(), bytes.NewReader(data), nil)
|
||||
}
|
||||
|
||||
func Load(ctx context.Context, reader io.Reader, capabilities datasource.CapabilitySet) (Registry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
limited := io.LimitReader(reader, maxCatalogBytes+1)
|
||||
decoder := json.NewDecoder(limited)
|
||||
decoder.DisallowUnknownFields()
|
||||
var catalog Catalog
|
||||
if err := decoder.Decode(&catalog); err != nil {
|
||||
return Registry{}, fmt.Errorf("decode metric catalog: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return Registry{}, errors.New("metric catalog contains trailing data")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
return New(catalog, capabilities)
|
||||
}
|
||||
|
||||
func New(catalog Catalog, capabilities datasource.CapabilitySet) (Registry, error) {
|
||||
if err := catalog.Validate(); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
canonical := struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Metrics []Definition `json:"metrics"`
|
||||
}{catalog.SchemaVersion, append([]Definition(nil), catalog.Metrics...)}
|
||||
for i := range canonical.Metrics {
|
||||
canonical.Metrics[i].RequiredCapabilities = append([]string(nil), canonical.Metrics[i].RequiredCapabilities...)
|
||||
canonical.Metrics[i].AllowedLabels = append([]string(nil), canonical.Metrics[i].AllowedLabels...)
|
||||
canonical.Metrics[i].AllowedVisualizations = append([]string(nil), canonical.Metrics[i].AllowedVisualizations...)
|
||||
}
|
||||
sort.Slice(canonical.Metrics, func(i, j int) bool {
|
||||
if canonical.Metrics[i].SemanticName == canonical.Metrics[j].SemanticName {
|
||||
return canonical.Metrics[i].Version < canonical.Metrics[j].Version
|
||||
}
|
||||
return canonical.Metrics[i].SemanticName < canonical.Metrics[j].SemanticName
|
||||
})
|
||||
encoded, err := json.Marshal(canonical)
|
||||
if err != nil {
|
||||
return Registry{}, fmt.Errorf("canonicalize metric catalog: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
return Registry{catalog: Catalog{SchemaVersion: catalog.SchemaVersion, Metrics: canonical.Metrics}, version: hex.EncodeToString(digest[:]), capabilities: append(datasource.CapabilitySet(nil), capabilities...)}, nil
|
||||
}
|
||||
|
||||
func (c Catalog) Validate() error {
|
||||
if c.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported metric catalog schema version %d", c.SchemaVersion)
|
||||
}
|
||||
if len(c.Metrics) == 0 || len(c.Metrics) > 500 {
|
||||
return errors.New("metric catalog must contain 1-500 metrics")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(c.Metrics))
|
||||
for _, metric := range c.Metrics {
|
||||
if err := metric.Validate(); err != nil {
|
||||
return fmt.Errorf("metric %q: %w", metric.SemanticName, err)
|
||||
}
|
||||
key := fmt.Sprintf("%s@%d", metric.SemanticName, metric.Version)
|
||||
if _, ok := seen[key]; ok {
|
||||
return fmt.Errorf("duplicate metric %q", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Definition) Validate() error {
|
||||
if !semanticNamePattern.MatchString(d.SemanticName) || len(d.SemanticName) > 160 {
|
||||
return errors.New("semantic name is invalid")
|
||||
}
|
||||
if d.Version < 1 {
|
||||
return errors.New("version must be positive")
|
||||
}
|
||||
if strings.TrimSpace(d.Description) == "" || len(d.Description) > 1000 {
|
||||
return errors.New("description is required and bounded")
|
||||
}
|
||||
if len(d.Unit) > 40 {
|
||||
return errors.New("unit is too long")
|
||||
}
|
||||
if !oneOf(d.ValueKind, "gauge", "counter", "state", "histogram") || !oneOf(d.SourceKind, "prometheus", "inventory", "event", "derived") {
|
||||
return errors.New("value or source kind is invalid")
|
||||
}
|
||||
if strings.TrimSpace(d.QueryTemplate) == "" || len(d.QueryTemplate) > 4000 {
|
||||
return errors.New("query template is required and bounded")
|
||||
}
|
||||
if strings.Contains(d.QueryTemplate, "${") {
|
||||
return errors.New("query template contains unsupported interpolation")
|
||||
}
|
||||
for _, match := range placeholderPattern.FindAllStringSubmatch(d.QueryTemplate, -1) {
|
||||
if _, ok := allowedPlaceholders[match[1]]; !ok {
|
||||
return fmt.Errorf("query template placeholder %q is not allowed", match[1])
|
||||
}
|
||||
}
|
||||
if len(d.RequiredCapabilities) > 20 {
|
||||
return errors.New("too many required capabilities")
|
||||
}
|
||||
if err := uniqueBounded(d.RequiredCapabilities, 100, nil); err != nil {
|
||||
return fmt.Errorf("required capabilities: %w", err)
|
||||
}
|
||||
if len(d.AllowedLabels) > 30 {
|
||||
return errors.New("too many allowed labels")
|
||||
}
|
||||
if err := uniqueBounded(d.AllowedLabels, 0, labelPattern); err != nil {
|
||||
return fmt.Errorf("allowed labels: %w", err)
|
||||
}
|
||||
if d.DefaultAggregation != "" && !oneOf(d.DefaultAggregation, "none", "avg", "sum", "min", "max", "rate", "increase", "p50", "p95", "p99") {
|
||||
return errors.New("default aggregation is invalid")
|
||||
}
|
||||
if d.CardinalityBudget < 1 || d.CardinalityBudget > 10000 {
|
||||
return errors.New("cardinality budget is out of range")
|
||||
}
|
||||
if d.Limits.MaxRangeSeconds < 60 || d.Limits.MaxSeries < 1 || d.Limits.MaxSeries > 10000 || d.Limits.MaxPoints < 10 || d.Limits.MaxPoints > 1000000 || d.Limits.TimeoutSeconds < 1 || d.Limits.TimeoutSeconds > 120 {
|
||||
return errors.New("limits are out of range")
|
||||
}
|
||||
if len(d.AllowedVisualizations) == 0 || len(d.AllowedVisualizations) > 30 {
|
||||
return errors.New("at least one visualization is required")
|
||||
}
|
||||
if err := uniqueBounded(d.AllowedVisualizations, 0, nil); err != nil {
|
||||
return fmt.Errorf("visualizations: %w", err)
|
||||
}
|
||||
if d.FreshnessSeconds < 1 {
|
||||
return errors.New("freshness must be positive")
|
||||
}
|
||||
for _, threshold := range d.DefaultThresholds {
|
||||
if !oneOf(threshold.State, "attention", "degraded", "critical") || !oneOf(threshold.Operator, ">", ">=", "<", "<=", "==", "!=") || threshold.DurationSeconds < 0 {
|
||||
return errors.New("threshold is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueBounded(values []string, maxLen int, pattern *regexp.Regexp) error {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if maxLen > 0 && len(value) > maxLen {
|
||||
return errors.New("value is too long")
|
||||
}
|
||||
if pattern != nil && !pattern.MatchString(value) {
|
||||
return fmt.Errorf("value %q is invalid", value)
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return fmt.Errorf("duplicate value %q", value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r Registry) Find(semanticName string) (Definition, bool) {
|
||||
for _, metric := range r.catalog.Metrics {
|
||||
if metric.SemanticName == semanticName {
|
||||
return metric, true
|
||||
}
|
||||
}
|
||||
return Definition{}, false
|
||||
}
|
||||
|
||||
func (r Registry) Version() string { return r.version }
|
||||
func (r Registry) Metrics() []Definition { return append([]Definition(nil), r.catalog.Metrics...) }
|
||||
func (r Registry) Response() Response {
|
||||
return Response{SchemaVersion: r.catalog.SchemaVersion, Version: r.version, Metrics: r.Metrics(), Bindings: r.Bindings()}
|
||||
}
|
||||
func (r Registry) Bindings() []Binding {
|
||||
result := make([]Binding, 0, len(r.catalog.Metrics))
|
||||
for _, metric := range r.catalog.Metrics {
|
||||
binding := Binding{SemanticName: metric.SemanticName, Version: metric.Version, State: BindingSupported}
|
||||
for _, required := range metric.RequiredCapabilities {
|
||||
capability, ok := r.capabilities.Find(required)
|
||||
if !ok {
|
||||
binding.State = BindingUnsupported
|
||||
binding.MissingCapabilities = append(binding.MissingCapabilities, required)
|
||||
continue
|
||||
}
|
||||
switch capability.State {
|
||||
case datasource.CapabilityEnabled:
|
||||
case datasource.CapabilityUnsupported:
|
||||
binding.State = BindingUnsupported
|
||||
binding.UnsupportedCapabilities = append(binding.UnsupportedCapabilities, required)
|
||||
case datasource.CapabilityDisabled:
|
||||
if binding.State == BindingSupported {
|
||||
binding.State = BindingUnavailable
|
||||
}
|
||||
binding.UnavailableCapabilities = append(binding.UnavailableCapabilities, required)
|
||||
case datasource.CapabilityUnavailable:
|
||||
if binding.State == BindingSupported {
|
||||
binding.State = BindingUnavailable
|
||||
}
|
||||
binding.UnavailableCapabilities = append(binding.UnavailableCapabilities, required)
|
||||
default:
|
||||
if binding.State == BindingSupported {
|
||||
binding.State = BindingUnavailable
|
||||
}
|
||||
binding.UnavailableCapabilities = append(binding.UnavailableCapabilities, required)
|
||||
}
|
||||
}
|
||||
result = append(result, binding)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package metriccatalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/itworx/pulse/internal/datasource"
|
||||
)
|
||||
|
||||
func TestDefaultContainerMetricsUseUnraidCAdvisorNameLabel(t *testing.T) {
|
||||
catalog, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, semanticName := range []string{"container.cpu.utilization", "container.memory.used"} {
|
||||
metric, ok := catalog.Find(semanticName)
|
||||
if !ok {
|
||||
t.Fatalf("metric %q missing", semanticName)
|
||||
}
|
||||
if !strings.Contains(metric.QueryTemplate, `name={{container}}`) || strings.Contains(metric.QueryTemplate, `container={{container}}`) {
|
||||
t.Fatalf("metric %q does not target the Unraid cAdvisor name label: %s", semanticName, metric.QueryTemplate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCatalogValidatesAndHasDeterministicVersion(t *testing.T) {
|
||||
first, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Version() == "" || first.Version() != second.Version() {
|
||||
t.Fatalf("versions are not deterministic: %q/%q", first.Version(), second.Version())
|
||||
}
|
||||
if len(first.Metrics()) < 1 || len(first.Bindings()) != len(first.Metrics()) {
|
||||
t.Fatal("catalog metrics/bindings mismatch")
|
||||
}
|
||||
for _, binding := range first.Bindings() {
|
||||
if binding.State != BindingUnsupported || len(binding.MissingCapabilities) == 0 {
|
||||
t.Fatalf("unconfigured binding was not explicit: %+v", binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityBindingDistinguishesSupportedAndUnavailable(t *testing.T) {
|
||||
catalog, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metric := catalog.Metrics()[0]
|
||||
capability := metric.RequiredCapabilities[0]
|
||||
supported, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: []Definition{metric}}, datasource.CapabilitySet{{ID: capability, Version: "v1", State: datasource.CapabilityEnabled}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := supported.Bindings()[0].State; got != BindingSupported {
|
||||
t.Fatalf("state=%q", got)
|
||||
}
|
||||
unavailable, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: []Definition{metric}}, datasource.CapabilitySet{{ID: capability, Version: "v1", State: datasource.CapabilityUnavailable}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := unavailable.Bindings()[0].State; got != BindingUnavailable {
|
||||
t.Fatalf("state=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogVersionChangesWhenDefinitionChanges(t *testing.T) {
|
||||
original, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metrics := original.Metrics()
|
||||
metrics[0].Description += " changed"
|
||||
changed, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: metrics}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if original.Version() == changed.Version() {
|
||||
t.Fatal("catalog version did not change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownFieldsAndHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := Load(ctx, bytes.NewBufferString(`{"schemaVersion":1,"metrics":[]}`), nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error=%v", err)
|
||||
}
|
||||
// Plain encoding/json intentionally tolerates unknown fields; only Load is strict.
|
||||
var catalog Catalog
|
||||
if err := json.Unmarshal([]byte(`{"schemaVersion":1,"metrics":[],"unexpected":true}`), &catalog); err != nil {
|
||||
t.Fatalf("direct unmarshal rejected an unknown field: %v", err)
|
||||
}
|
||||
if _, err := Load(context.Background(), bytes.NewBufferString(`{"schemaVersion":1,"metrics":[],"unexpected":true}`), nil); err == nil {
|
||||
t.Fatal("unknown field accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsUnknownPlaceholderDuplicateAndInvalidLimit(t *testing.T) {
|
||||
base := Definition{SemanticName: "host.cpu.utilization", Version: 1, Description: "cpu", Unit: "percent", ValueKind: "gauge", SourceKind: "prometheus", QueryTemplate: "up{host={{not_allowed}}}", AllowedLabels: []string{"instance"}, CardinalityBudget: 1, Limits: Limits{MaxRangeSeconds: 60, MaxSeries: 1, MaxPoints: 10, TimeoutSeconds: 1}, AllowedVisualizations: []string{"stat"}, FreshnessSeconds: 1}
|
||||
if _, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: []Definition{base}}, nil); err == nil {
|
||||
t.Fatal("unknown placeholder accepted")
|
||||
}
|
||||
base.QueryTemplate = "up"
|
||||
base.AllowedLabels = []string{"instance", "instance"}
|
||||
if _, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: []Definition{base}}, nil); err == nil {
|
||||
t.Fatal("duplicate label accepted")
|
||||
}
|
||||
base.AllowedLabels = []string{"instance"}
|
||||
base.Limits.MaxPoints = 9
|
||||
if _, err := New(Catalog{SchemaVersion: SchemaVersion, Metrics: []Definition{base}}, nil); err == nil {
|
||||
t.Fatal("invalid points accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"metrics": [
|
||||
{
|
||||
"semanticName": "host.cpu.utilization",
|
||||
"version": 1,
|
||||
"description": "Average host CPU utilization excluding idle time.",
|
||||
"unit": "percent",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\",instance={{instance}}}[{{window}}])) * 100)",
|
||||
"requiredCapabilities": [
|
||||
"node-exporter.cpu"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance"
|
||||
],
|
||||
"defaultAggregation": "avg",
|
||||
"cardinalityBudget": 10,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 2592000,
|
||||
"maxSeries": 10,
|
||||
"maxPoints": 20000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge"
|
||||
],
|
||||
"freshnessSeconds": 30,
|
||||
"defaultThresholds": [
|
||||
{
|
||||
"state": "attention",
|
||||
"operator": ">=",
|
||||
"value": 75,
|
||||
"durationSeconds": 300
|
||||
},
|
||||
{
|
||||
"state": "degraded",
|
||||
"operator": ">=",
|
||||
"value": 90,
|
||||
"durationSeconds": 300
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"semanticName": "host.memory.utilization",
|
||||
"version": 1,
|
||||
"description": "Host memory utilization based on available memory.",
|
||||
"unit": "percent",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "(1 - node_memory_MemAvailable_bytes{instance={{instance}}} / node_memory_MemTotal_bytes{instance={{instance}}}) * 100",
|
||||
"requiredCapabilities": [
|
||||
"node-exporter.memory"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance"
|
||||
],
|
||||
"defaultAggregation": "avg",
|
||||
"cardinalityBudget": 10,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 2592000,
|
||||
"maxSeries": 10,
|
||||
"maxPoints": 20000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge"
|
||||
],
|
||||
"freshnessSeconds": 30,
|
||||
"defaultThresholds": [
|
||||
{
|
||||
"state": "attention",
|
||||
"operator": ">=",
|
||||
"value": 80,
|
||||
"durationSeconds": 300
|
||||
},
|
||||
{
|
||||
"state": "degraded",
|
||||
"operator": ">=",
|
||||
"value": 92,
|
||||
"durationSeconds": 300
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"semanticName": "container.cpu.utilization",
|
||||
"version": 1,
|
||||
"description": "Container CPU utilization normalized to a percentage of one core unless configured otherwise.",
|
||||
"unit": "percent",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "rate(container_cpu_usage_seconds_total{name={{container}}}[{{window}}]) * 100",
|
||||
"requiredCapabilities": [
|
||||
"container.cpu"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance",
|
||||
"container",
|
||||
"image"
|
||||
],
|
||||
"defaultAggregation": "avg",
|
||||
"cardinalityBudget": 500,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 2592000,
|
||||
"maxSeries": 200,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"ranked-list",
|
||||
"table"
|
||||
],
|
||||
"freshnessSeconds": 30,
|
||||
"defaultThresholds": []
|
||||
},
|
||||
{
|
||||
"semanticName": "container.memory.used",
|
||||
"version": 1,
|
||||
"description": "Current working-set memory used by a container.",
|
||||
"unit": "bytes",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "container_memory_working_set_bytes{name={{container}}}",
|
||||
"requiredCapabilities": [
|
||||
"container.memory"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance",
|
||||
"container",
|
||||
"image"
|
||||
],
|
||||
"defaultAggregation": "avg",
|
||||
"cardinalityBudget": 500,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 2592000,
|
||||
"maxSeries": 200,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"ranked-list",
|
||||
"table"
|
||||
],
|
||||
"freshnessSeconds": 30,
|
||||
"defaultThresholds": []
|
||||
},
|
||||
{
|
||||
"semanticName": "storage.disk.temperature",
|
||||
"version": 1,
|
||||
"description": "Observed disk temperature.",
|
||||
"unit": "celsius",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "smartctl_device_temperature{device={{device}}}",
|
||||
"requiredCapabilities": [
|
||||
"smart.temperature"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance",
|
||||
"device",
|
||||
"model",
|
||||
"serial_hash"
|
||||
],
|
||||
"defaultAggregation": "max",
|
||||
"cardinalityBudget": 100,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 100,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge",
|
||||
"heatmap",
|
||||
"table",
|
||||
"storage-map"
|
||||
],
|
||||
"freshnessSeconds": 300,
|
||||
"defaultThresholds": [
|
||||
{
|
||||
"state": "attention",
|
||||
"operator": ">=",
|
||||
"value": 45,
|
||||
"durationSeconds": 300
|
||||
},
|
||||
{
|
||||
"state": "degraded",
|
||||
"operator": ">=",
|
||||
"value": 50,
|
||||
"durationSeconds": 300
|
||||
},
|
||||
{
|
||||
"state": "critical",
|
||||
"operator": ">=",
|
||||
"value": 60,
|
||||
"durationSeconds": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"semanticName": "storage.disk.temperature.maximum",
|
||||
"version": 1,
|
||||
"description": "Maximum observed disk temperature across the approved source set.",
|
||||
"unit": "celsius",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "max(max_over_time(smartctl_device_temperature[{{window}}]))",
|
||||
"requiredCapabilities": [
|
||||
"smart.temperature"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance",
|
||||
"device",
|
||||
"model",
|
||||
"serial_hash"
|
||||
],
|
||||
"defaultAggregation": "max",
|
||||
"cardinalityBudget": 1,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 1,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge"
|
||||
],
|
||||
"freshnessSeconds": 300,
|
||||
"defaultThresholds": []
|
||||
},
|
||||
{
|
||||
"semanticName": "storage.pool.utilization.maximum",
|
||||
"version": 1,
|
||||
"description": "Highest used-capacity percentage across all storage pools. Placeholder-free variant of storage.pool.utilization for host-agnostic alert rules that need no per-pool scope binding.",
|
||||
"unit": "percent",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "derived",
|
||||
"queryTemplate": "max(pulse_storage_pool_used_bytes / pulse_storage_pool_capacity_bytes * 100)",
|
||||
"requiredCapabilities": [
|
||||
"storage.pool.capacity"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance"
|
||||
],
|
||||
"defaultAggregation": "max",
|
||||
"cardinalityBudget": 10,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 10,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge"
|
||||
],
|
||||
"freshnessSeconds": 120,
|
||||
"defaultThresholds": [
|
||||
{
|
||||
"state": "attention",
|
||||
"operator": ">=",
|
||||
"value": 80,
|
||||
"durationSeconds": 600
|
||||
},
|
||||
{
|
||||
"state": "degraded",
|
||||
"operator": ">=",
|
||||
"value": 90,
|
||||
"durationSeconds": 600
|
||||
},
|
||||
{
|
||||
"state": "critical",
|
||||
"operator": ">=",
|
||||
"value": 97,
|
||||
"durationSeconds": 300
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"semanticName": "storage.pool.utilization",
|
||||
"version": 1,
|
||||
"description": "Used capacity as a percentage of usable pool capacity.",
|
||||
"unit": "percent",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "derived",
|
||||
"queryTemplate": "pulse_storage_pool_used_bytes{pool={{pool}}} / pulse_storage_pool_capacity_bytes{pool={{pool}}} * 100",
|
||||
"requiredCapabilities": [
|
||||
"storage.pool.capacity"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"instance",
|
||||
"pool",
|
||||
"filesystem"
|
||||
],
|
||||
"defaultAggregation": "max",
|
||||
"cardinalityBudget": 100,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 100,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"gauge",
|
||||
"table",
|
||||
"storage-map"
|
||||
],
|
||||
"freshnessSeconds": 120,
|
||||
"defaultThresholds": [
|
||||
{
|
||||
"state": "attention",
|
||||
"operator": ">=",
|
||||
"value": 80,
|
||||
"durationSeconds": 600
|
||||
},
|
||||
{
|
||||
"state": "degraded",
|
||||
"operator": ">=",
|
||||
"value": 90,
|
||||
"durationSeconds": 600
|
||||
},
|
||||
{
|
||||
"state": "critical",
|
||||
"operator": ">=",
|
||||
"value": 97,
|
||||
"durationSeconds": 300
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"semanticName": "service.response_time",
|
||||
"version": 1,
|
||||
"description": "End-to-end probe response time.",
|
||||
"unit": "seconds",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "probe_duration_seconds{probe_id={{probe_id}}}",
|
||||
"requiredCapabilities": [
|
||||
"probe.duration"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"probe_id",
|
||||
"service_id",
|
||||
"probe_type"
|
||||
],
|
||||
"defaultAggregation": "p95",
|
||||
"cardinalityBudget": 1000,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 500,
|
||||
"maxPoints": 200000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"ranked-list",
|
||||
"table",
|
||||
"heatmap",
|
||||
"service-matrix"
|
||||
],
|
||||
"freshnessSeconds": 120,
|
||||
"defaultThresholds": []
|
||||
},
|
||||
{
|
||||
"semanticName": "service.availability",
|
||||
"version": 1,
|
||||
"description": "Probe success represented as 0 or 1 and aggregated to availability.",
|
||||
"unit": "ratio",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "probe_success{probe_id={{probe_id}}}",
|
||||
"requiredCapabilities": [
|
||||
"probe.success"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"probe_id",
|
||||
"service_id",
|
||||
"probe_type"
|
||||
],
|
||||
"defaultAggregation": "avg",
|
||||
"cardinalityBudget": 1000,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 500,
|
||||
"maxPoints": 200000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries",
|
||||
"table",
|
||||
"service-matrix"
|
||||
],
|
||||
"freshnessSeconds": 120,
|
||||
"defaultThresholds": []
|
||||
},
|
||||
{
|
||||
"semanticName": "service.availability.minimum",
|
||||
"version": 1,
|
||||
"description": "Minimum probe availability across the approved service source set.",
|
||||
"unit": "ratio",
|
||||
"valueKind": "gauge",
|
||||
"sourceKind": "prometheus",
|
||||
"queryTemplate": "min(min_over_time(probe_success[{{window}}]))",
|
||||
"requiredCapabilities": [
|
||||
"probe.success"
|
||||
],
|
||||
"allowedLabels": [
|
||||
"probe_id",
|
||||
"service_id",
|
||||
"probe_type"
|
||||
],
|
||||
"defaultAggregation": "min",
|
||||
"cardinalityBudget": 1,
|
||||
"limits": {
|
||||
"maxRangeSeconds": 7776000,
|
||||
"maxSeries": 1,
|
||||
"maxPoints": 100000,
|
||||
"timeoutSeconds": 10
|
||||
},
|
||||
"allowedVisualizations": [
|
||||
"stat",
|
||||
"timeseries"
|
||||
],
|
||||
"freshnessSeconds": 120,
|
||||
"defaultThresholds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user