Skip to content

Commit 77ff3d4

Browse files
authored
feat: PC-13045 Good total single query experimental support (#458)
## Motivation Enabling support for gathering godd and total points using single query. Currently for Splunk only. Sample YAML: ``` --- apiVersion: n9/v1alpha kind: SLO metadata: name: splunk-counts-calendar project: splunk spec: service: splunk-service indicator: metricSource: kind: Agent name: splunk project: splunk timeWindows: - unit: Day count: 1 calendar: startTime: 2021-04-09 00:00:00 timeZone: Europe/Warsaw budgetingMethod: Occurrences objectives: - displayName: So so target: 0.80 name: objective-1 countMetrics: incremental: false goodTotal: splunk: query: |- | mstats avg("spl.intr.resource_usage.IOWait.data.avg_cpu_pct") as n9good WHERE index="_metrics" span=15s | join type=left _time [ | mstats avg("spl.intr.resource_usage.IOWait.data.max_cpus_pct") as n9total WHERE index="_metrics" span=15s ] | rename _time as n9time | fields n9time n9good n9total ``` ## Summary Added new `goodTotal` field to count metrics spec Added validation for splunk query with new field names `n9time`, `n9good`, `n9total` ## Testing - Unit tests - Manual planned tests after sloctl and platform changes No release notes, as this is in experimental stage.
1 parent 1a7c326 commit 77ff3d4

9 files changed

+366
-8
lines changed

cspell.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ words:
100100
- mockgen
101101
- mprofile
102102
- msgf
103+
- mstats
103104
- msteams
104105
- ningxia
105106
- nobl

manifest/v1alpha/slo/metrics.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ type CountMetricsSpec struct {
1111
Incremental *bool `json:"incremental"`
1212
GoodMetric *MetricSpec `json:"good,omitempty"`
1313
BadMetric *MetricSpec `json:"bad,omitempty"`
14-
TotalMetric *MetricSpec `json:"total"`
14+
TotalMetric *MetricSpec `json:"total,omitempty"`
15+
// Experimental: Splunk only, a single query returning both good and total counts.
16+
GoodTotalMetric *MetricSpec `json:"goodTotal,omitempty"`
1517
}
1618

1719
// RawMetricSpec represents integration with a metric source for a particular objective.
@@ -143,6 +145,9 @@ func (s *Spec) CountMetricsCount() int {
143145
if objective.CountMetrics.BadMetric != nil {
144146
count++
145147
}
148+
if objective.CountMetrics.GoodTotalMetric != nil {
149+
count++
150+
}
146151
}
147152
}
148153
return count
@@ -168,6 +173,10 @@ func (s *Spec) CountMetrics() []*MetricSpec {
168173
countMetrics[i] = objective.CountMetrics.BadMetric
169174
i++
170175
}
176+
if objective.CountMetrics.GoodTotalMetric != nil {
177+
countMetrics[i] = objective.CountMetrics.GoodTotalMetric
178+
i++
179+
}
171180
}
172181
return countMetrics
173182
}

manifest/v1alpha/slo/metrics_bigquery_test.go

+12
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ func TestBigQuery_CountMetrics(t *testing.T) {
1414
err := validate(slo)
1515
testutils.AssertNoError(t, slo, err)
1616
})
17+
t.Run("unsupported goodTotal single query", func(t *testing.T) {
18+
slo := validCountMetricSLO(v1alpha.BigQuery)
19+
slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{
20+
Incremental: ptr(false),
21+
GoodTotalMetric: validMetricSpec(v1alpha.BigQuery),
22+
}
23+
err := validate(slo)
24+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
25+
Prop: "spec.objectives[0].countMetrics.goodTotal",
26+
Code: joinErrorCodes(errCodeSingleQueryGoodOverTotalDisabled, validation.ErrorCodeOneOf),
27+
})
28+
})
1729
t.Run("projectId must be the same for good and total", func(t *testing.T) {
1830
slo := validCountMetricSLO(v1alpha.BigQuery)
1931
slo.Spec.Objectives[0].CountMetrics.TotalMetric.BigQuery.ProjectID = "1"

manifest/v1alpha/slo/metrics_splunk.go

+33
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,33 @@ package slo
33
import (
44
"regexp"
55

6+
"github.com/pkg/errors"
7+
68
"github.com/nobl9/nobl9-go/internal/validation"
9+
"github.com/nobl9/nobl9-go/manifest/v1alpha"
710
)
811

912
// SplunkMetric represents metric from Splunk
1013
type SplunkMetric struct {
1114
Query *string `json:"query"`
1215
}
1316

17+
var splunkCountMetricsLevelValidation = validation.New[CountMetricsSpec](
18+
validation.For(validation.GetSelf[CountMetricsSpec]()).
19+
Rules(
20+
validation.NewSingleRule(func(c CountMetricsSpec) error {
21+
if c.GoodTotalMetric != nil {
22+
if c.GoodMetric != nil || c.BadMetric != nil || c.TotalMetric != nil {
23+
return errors.New("goodTotal is mutually exclusive with good, bad, and total")
24+
}
25+
}
26+
return nil
27+
}).WithErrorCode(validation.ErrorCodeMutuallyExclusive)),
28+
).When(
29+
whenCountMetricsIs(v1alpha.Splunk),
30+
validation.WhenDescription("countMetrics is splunk"),
31+
)
32+
1433
var splunkValidation = validation.New[SplunkMetric](
1534
validation.ForPointer(func(s SplunkMetric) *string { return s.Query }).
1635
WithName("query").
@@ -24,3 +43,17 @@ var splunkValidation = validation.New[SplunkMetric](
2443
"index=svc-events", `"index"=svc-events`).
2544
WithDetails(`query has to contain index=<NAME> or "index"=<NAME>`)),
2645
)
46+
47+
var splunkSingleQueryValidation = validation.New[SplunkMetric](
48+
validation.ForPointer(func(s SplunkMetric) *string { return s.Query }).
49+
WithName("query").
50+
Required().
51+
Cascade(validation.CascadeModeStop).
52+
Rules(validation.StringNotEmpty()).
53+
Rules(
54+
validation.StringContains("n9time", "n9good", "n9total"),
55+
validation.StringMatchRegexp(
56+
regexp.MustCompile(`(\bindex\s*=.+)|("\bindex"\s*=.+)`),
57+
"index=svc-events", `"index"=svc-events`).
58+
WithDetails(`query has to contain index=<NAME> or "index"=<NAME>`)),
59+
)

manifest/v1alpha/slo/metrics_splunk_test.go

+113
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,116 @@ fields n9time n9value`,
7777
}
7878
})
7979
}
80+
81+
func TestSplunk_CountMetrics_SingleQuery(t *testing.T) {
82+
t.Run("passes", func(t *testing.T) {
83+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
84+
err := validate(slo)
85+
testutils.AssertNoError(t, slo, err)
86+
})
87+
t.Run("required", func(t *testing.T) {
88+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
89+
slo.Spec.Objectives[0].CountMetrics.GoodTotalMetric.Splunk.Query = nil
90+
err := validate(slo)
91+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
92+
Prop: "spec.objectives[0].countMetrics.goodTotal.splunk.query",
93+
Code: validation.ErrorCodeRequired,
94+
})
95+
})
96+
t.Run("empty", func(t *testing.T) {
97+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
98+
slo.Spec.Objectives[0].CountMetrics.GoodTotalMetric.Splunk.Query = ptr("")
99+
err := validate(slo)
100+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
101+
Prop: "spec.objectives[0].countMetrics.goodTotal.splunk.query",
102+
Code: validation.ErrorCodeStringNotEmpty,
103+
})
104+
})
105+
t.Run("goodTotal mixed with total", func(t *testing.T) {
106+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
107+
slo.Spec.Objectives[0].CountMetrics.TotalMetric = validMetricSpec(v1alpha.Splunk)
108+
err := validate(slo)
109+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
110+
Prop: "spec.objectives[0].countMetrics",
111+
Code: validation.ErrorCodeMutuallyExclusive,
112+
})
113+
})
114+
t.Run("goodTotal mixed with good", func(t *testing.T) {
115+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
116+
slo.Spec.Objectives[0].CountMetrics.GoodMetric = validMetricSpec(v1alpha.Splunk)
117+
err := validate(slo)
118+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
119+
Prop: "spec.objectives[0].countMetrics",
120+
Code: validation.ErrorCodeMutuallyExclusive,
121+
})
122+
})
123+
t.Run("goodTotal mixed with bad", func(t *testing.T) {
124+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
125+
slo.Spec.Objectives[0].CountMetrics.BadMetric = validMetricSpec(v1alpha.Splunk)
126+
err := validate(slo)
127+
testutils.AssertContainsErrors(t, slo, err, 2, testutils.ExpectedError{
128+
Prop: "spec.objectives[0].countMetrics.bad",
129+
Code: joinErrorCodes(errCodeBadOverTotalDisabled, validation.ErrorCodeOneOf),
130+
}, testutils.ExpectedError{
131+
Prop: "spec.objectives[0].countMetrics",
132+
Code: validation.ErrorCodeMutuallyExclusive,
133+
})
134+
})
135+
t.Run("invalid query", func(t *testing.T) {
136+
tests := map[string]struct {
137+
Query string
138+
ExpectedCode string
139+
}{
140+
"missing n9time": {
141+
Query: `
142+
| mstats avg("spl.intr.resource_usage.IOWait.data.avg_cpu_pct") as n9good WHERE index="_metrics" span=15s
143+
| join type=left _time [
144+
| mstats avg("spl.intr.resource_usage.IOWait.data.max_cpus_pct") as n9total WHERE index="_metrics" span=15s
145+
]
146+
| fields _time n9good n9total`,
147+
ExpectedCode: validation.ErrorCodeStringContains,
148+
},
149+
"missing n9good": {
150+
Query: `
151+
| mstats avg("spl.intr.resource_usage.IOWait.data.avg_cpu_pct") as good WHERE index="_metrics" span=15s
152+
| join type=left _time [
153+
| mstats avg("spl.intr.resource_usage.IOWait.data.max_cpus_pct") as n9total WHERE index="_metrics" span=15s
154+
]
155+
| rename _time as n9time
156+
| fields n9time good n9total`,
157+
ExpectedCode: validation.ErrorCodeStringContains,
158+
},
159+
"missing n9total": {
160+
Query: `
161+
| mstats avg("spl.intr.resource_usage.IOWait.data.avg_cpu_pct") as n9good WHERE index="_metrics" span=15s
162+
| join type=left _time [
163+
| mstats avg("spl.intr.resource_usage.IOWait.data.max_cpus_pct") as total WHERE index="_metrics" span=15s
164+
]
165+
| rename _time as n9time
166+
| fields n9time n9good total`,
167+
ExpectedCode: validation.ErrorCodeStringContains,
168+
},
169+
"missing index": {
170+
Query: `
171+
| mstats avg("spl.intr.resource_usage.IOWait.data.avg_cpu_pct") as n9good span=15s
172+
| join type=left _time [
173+
| mstats avg("spl.intr.resource_usage.IOWait.data.max_cpus_pct") as n9total span=15s
174+
]
175+
| rename _time as n9time
176+
| fields n9time n9good n9total`,
177+
ExpectedCode: validation.ErrorCodeStringMatchRegexp,
178+
},
179+
}
180+
for name, test := range tests {
181+
t.Run(name, func(t *testing.T) {
182+
slo := validSingleQueryGoodOverTotalCountMetricSLO(v1alpha.Splunk)
183+
slo.Spec.Objectives[0].CountMetrics.GoodTotalMetric.Splunk.Query = ptr(test.Query)
184+
err := validate(slo)
185+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
186+
Prop: "spec.objectives[0].countMetrics.goodTotal.splunk.query",
187+
Code: test.ExpectedCode,
188+
})
189+
})
190+
}
191+
})
192+
}

manifest/v1alpha/slo/metrics_test.go

+28
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package slo
22

33
import (
4+
"slices"
45
"testing"
56

7+
"github.com/nobl9/nobl9-go/internal/testutils"
8+
"github.com/nobl9/nobl9-go/internal/validation"
9+
610
"github.com/stretchr/testify/assert"
711

812
"github.com/nobl9/nobl9-go/manifest/v1alpha"
@@ -21,3 +25,27 @@ func TestQuery(t *testing.T) {
2125
assert.NotEmpty(t, spec)
2226
}
2327
}
28+
29+
func Test_SingleQueryDisabled(t *testing.T) {
30+
skippedDataSources := []v1alpha.DataSourceType{
31+
v1alpha.ThousandEyes, // query is forbidden for this plugin
32+
}
33+
for _, src := range v1alpha.DataSourceTypeValues() {
34+
if slices.Contains(singleQueryGoodOverTotalEnabledSources, src) {
35+
continue
36+
}
37+
if slices.Contains(skippedDataSources, src) {
38+
continue
39+
}
40+
slo := validCountMetricSLO(src)
41+
slo.Spec.Objectives[0].CountMetrics = &CountMetricsSpec{
42+
Incremental: ptr(false),
43+
GoodTotalMetric: validMetricSpec(src),
44+
}
45+
err := validate(slo)
46+
testutils.AssertContainsErrors(t, slo, err, 1, testutils.ExpectedError{
47+
Prop: "spec.objectives[0].countMetrics.goodTotal",
48+
Code: joinErrorCodes(errCodeSingleQueryGoodOverTotalDisabled, validation.ErrorCodeOneOf),
49+
})
50+
}
51+
}

manifest/v1alpha/slo/metrics_validation.go

+37-7
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import (
1111
)
1212

1313
const (
14-
errCodeExactlyOneMetricType = "exactly_one_metric_type"
15-
errCodeBadOverTotalDisabled = "bad_over_total_disabled"
16-
errCodeExactlyOneMetricSpecType = "exactly_one_metric_spec_type"
17-
errCodeEitherBadOrGoodCountMetric = "either_bad_or_good_count_metric"
18-
errCodeTimeSliceTarget = "time_slice_target"
14+
errCodeExactlyOneMetricType = "exactly_one_metric_type"
15+
errCodeBadOverTotalDisabled = "bad_over_total_disabled"
16+
errCodeSingleQueryGoodOverTotalDisabled = "single_query_good_over_total_disabled"
17+
errCodeExactlyOneMetricSpecType = "exactly_one_metric_spec_type"
18+
errCodeEitherBadOrGoodCountMetric = "either_bad_or_good_count_metric"
19+
errCodeTimeSliceTarget = "time_slice_target"
1920
)
2021

2122
var specMetricsValidation = validation.New[Spec](
@@ -61,13 +62,13 @@ var countMetricsSpecValidation = validation.New[CountMetricsSpec](
6162
sumoLogicCountMetricsLevelValidation,
6263
instanaCountMetricsLevelValidation,
6364
redshiftCountMetricsLevelValidation,
64-
bigQueryCountMetricsLevelValidation),
65+
bigQueryCountMetricsLevelValidation,
66+
splunkCountMetricsLevelValidation),
6567
validation.ForPointer(func(c CountMetricsSpec) *bool { return c.Incremental }).
6668
WithName("incremental").
6769
Required(),
6870
validation.ForPointer(func(c CountMetricsSpec) *MetricSpec { return c.TotalMetric }).
6971
WithName("total").
70-
Required().
7172
Include(
7273
metricSpecValidation,
7374
countMetricsValidation,
@@ -84,6 +85,12 @@ var countMetricsSpecValidation = validation.New[CountMetricsSpec](
8485
Include(
8586
countMetricsValidation,
8687
metricSpecValidation),
88+
validation.ForPointer(func(c CountMetricsSpec) *MetricSpec { return c.GoodTotalMetric }).
89+
WithName("goodTotal").
90+
Rules(oneOfSingleQueryGoodOverTotalValidationRule).
91+
Include(
92+
countMetricsValidation,
93+
singleQueryMetricSpecValidation),
8794
)
8895

8996
var rawMetricsValidation = validation.New[RawMetricSpec](
@@ -106,6 +113,12 @@ var countMetricsValidation = validation.New[MetricSpec](
106113
instanaCountMetricsValidation),
107114
)
108115

116+
var singleQueryMetricSpecValidation = validation.New[MetricSpec](
117+
validation.ForPointer(func(m MetricSpec) *SplunkMetric { return m.Splunk }).
118+
WithName("splunk").
119+
Include(splunkSingleQueryValidation),
120+
)
121+
109122
var metricSpecValidation = validation.New[MetricSpec](
110123
validation.ForPointer(func(m MetricSpec) *AppDynamicsMetric { return m.AppDynamics }).
111124
WithName("appDynamics").
@@ -200,6 +213,17 @@ var oneOfBadOverTotalValidationRule = validation.NewSingleRule(func(v MetricSpec
200213
return validation.OneOf(badOverTotalEnabledSources...).Validate(v.DataSourceType())
201214
}).WithErrorCode(errCodeBadOverTotalDisabled)
202215

216+
var singleQueryGoodOverTotalEnabledSources = []v1alpha.DataSourceType{
217+
v1alpha.Splunk,
218+
}
219+
220+
// Support for single query good/total metrics is experimental.
221+
// Splunk is the only datasource integration to have this feature
222+
// - extend the list while adding support for next integrations.
223+
var oneOfSingleQueryGoodOverTotalValidationRule = validation.NewSingleRule(func(v MetricSpec) error {
224+
return validation.OneOf(singleQueryGoodOverTotalEnabledSources...).Validate(v.DataSourceType())
225+
}).WithErrorCode(errCodeSingleQueryGoodOverTotalDisabled)
226+
203227
var exactlyOneMetricSpecTypeValidationRule = validation.NewSingleRule(func(v Spec) error {
204228
if v.Indicator == nil {
205229
return nil
@@ -401,6 +425,12 @@ var timeSliceTargetsValidationRule = validation.NewSingleRule[Spec](func(s Spec)
401425
// the count metrics is of the given type.
402426
func whenCountMetricsIs(typ v1alpha.DataSourceType) func(c CountMetricsSpec) bool {
403427
return func(c CountMetricsSpec) bool {
428+
if slices.Contains(singleQueryGoodOverTotalEnabledSources, typ) {
429+
if c.GoodTotalMetric != nil && typ != c.GoodTotalMetric.DataSourceType() {
430+
return false
431+
}
432+
return c.GoodMetric != nil || c.BadMetric != nil || c.TotalMetric != nil
433+
}
404434
if c.TotalMetric == nil {
405435
return false
406436
}

0 commit comments

Comments
 (0)