From 7fcdc8e21977c7d62b179942dd95e3bbefd53c46 Mon Sep 17 00:00:00 2001 From: Cedric Specht Date: Wed, 4 Mar 2026 12:30:55 +0100 Subject: [PATCH] Add time_slice SLO type support Add support for Datadog Time Slice SLOs alongside the existing metric and monitor SLO types. Time Slice SLOs use an SLI specification with metric queries, formulas, a comparator, and a threshold to determine what counts as good uptime in each time slice. - Add DatadogSLOTypeTimeSlice and related CRD types (DatadogSLOTimeSlice, DatadogSLOFormula, DatadogSLODataSourceQuery, DatadogSLOTimeSliceComparator) - Add TimeSlice field to DatadogSLOSpec following the existing pattern where each SLO type has its own dedicated payload field - Add validation for time_slice specs including cross-field rejection to prevent invalid type/field combinations - Add buildSliSpecification() mapping to the datadogV1 SDK types with hardcoded metrics data source - Add comprehensive unit tests for validation and API mapping - Regenerate CRDs and deepcopy/openapi code --- api/datadoghq/v1alpha1/datadogslo_types.go | 39 +++- .../v1alpha1/datadogslo_validation.go | 26 ++- .../v1alpha1/datadogslo_validation_test.go | 112 ++++++++++- .../v1alpha1/zz_generated.deepcopy.go | 21 ++ .../v1alpha1/zz_generated.openapi.go | 47 ++++- .../bases/v1/datadoghq.com_datadogslos.yaml | 36 ++++ .../datadoghq.com_datadogslos_v1alpha1.json | 51 +++++ internal/controller/datadogslo/slo.go | 36 ++++ internal/controller/datadogslo/slo_test.go | 190 ++++++++++++++++++ 9 files changed, 552 insertions(+), 6 deletions(-) diff --git a/api/datadoghq/v1alpha1/datadogslo_types.go b/api/datadoghq/v1alpha1/datadogslo_types.go index 9fd1c25e1..5ef8434dd 100644 --- a/api/datadoghq/v1alpha1/datadogslo_types.go +++ b/api/datadoghq/v1alpha1/datadogslo_types.go @@ -39,6 +39,10 @@ type DatadogSLOSpec struct { // Note that only the `sum by` aggregator is allowed, which sums all request counts. `Average`, `max`, nor `min` request aggregators are not supported. Query *DatadogSLOQuery `json:"query,omitempty"` + // TimeSlice defines the SLI specification for a time_slice SLO. Required if type is time_slice. + // It specifies a metric query and a comparator/threshold that determines what counts as good uptime. + TimeSlice *DatadogSLOTimeSlice `json:"timeSlice,omitempty"` + // Type is the type of the service level objective. Type DatadogSLOType `json:"type"` @@ -63,16 +67,45 @@ type DatadogSLOQuery struct { Denominator string `json:"denominator"` } +// DatadogSLOTimeSlice defines the SLI specification for a time_slice SLO. +// It specifies a metric query and a comparator/threshold that determines what counts as good uptime. +// The operator automatically wraps the query into the formula and named query structure required by the Datadog API. +// +k8s:openapi-gen=true +type DatadogSLOTimeSlice struct { + // Query is a Datadog metric query string that produces the SLI value. + Query string `json:"query"` + + // Comparator is the comparison operator used to compare the SLI value to the threshold. + // +kubebuilder:validation:Enum=">";">=";"<";"<=" + Comparator DatadogSLOTimeSliceComparator `json:"comparator"` + + // Threshold is the value against which the SLI is compared using the comparator to determine + // if a time slice is good or bad. + Threshold resource.Quantity `json:"threshold"` +} + +// DatadogSLOTimeSliceComparator is the comparator used to compare the SLI value to the threshold. +// +kubebuilder:validation:Enum=">";">=";"<";"<=" +type DatadogSLOTimeSliceComparator string + +const ( + DatadogSLOTimeSliceComparatorGreater DatadogSLOTimeSliceComparator = ">" + DatadogSLOTimeSliceComparatorGreaterEqual DatadogSLOTimeSliceComparator = ">=" + DatadogSLOTimeSliceComparatorLess DatadogSLOTimeSliceComparator = "<" + DatadogSLOTimeSliceComparatorLessEqual DatadogSLOTimeSliceComparator = "<=" +) + type DatadogSLOType string const ( - DatadogSLOTypeMetric DatadogSLOType = "metric" - DatadogSLOTypeMonitor DatadogSLOType = "monitor" + DatadogSLOTypeMetric DatadogSLOType = "metric" + DatadogSLOTypeMonitor DatadogSLOType = "monitor" + DatadogSLOTypeTimeSlice DatadogSLOType = "time_slice" ) func (t DatadogSLOType) IsValid() bool { switch t { - case DatadogSLOTypeMetric, DatadogSLOTypeMonitor: + case DatadogSLOTypeMetric, DatadogSLOTypeMonitor, DatadogSLOTypeTimeSlice: return true default: return false diff --git a/api/datadoghq/v1alpha1/datadogslo_validation.go b/api/datadoghq/v1alpha1/datadogslo_validation.go index 7feab740c..c3eb5aff4 100644 --- a/api/datadoghq/v1alpha1/datadogslo_validation.go +++ b/api/datadoghq/v1alpha1/datadogslo_validation.go @@ -24,7 +24,7 @@ func IsValidDatadogSLO(spec *DatadogSLOSpec) error { } if spec.Type != "" && !spec.Type.IsValid() { - errs = append(errs, fmt.Errorf("spec.Type must be one of the values: %s or %s", DatadogSLOTypeMonitor, DatadogSLOTypeMetric)) + errs = append(errs, fmt.Errorf("spec.Type must be one of the values: %s, %s, or %s", DatadogSLOTypeMonitor, DatadogSLOTypeMetric, DatadogSLOTypeTimeSlice)) } if spec.Type == DatadogSLOTypeMetric && spec.Query == nil { @@ -35,6 +35,30 @@ func IsValidDatadogSLO(spec *DatadogSLOSpec) error { errs = append(errs, fmt.Errorf("spec.MonitorIDs must be defined when spec.Type is monitor")) } + if spec.Type == DatadogSLOTypeTimeSlice { + if spec.TimeSlice == nil { + errs = append(errs, fmt.Errorf("spec.TimeSlice must be defined when spec.Type is time_slice")) + } else { + if spec.TimeSlice.Query == "" { + errs = append(errs, fmt.Errorf("spec.TimeSlice.Query must be defined")) + } + } + } + + // Cross-field validation: reject fields that don't belong to the specified type. + if spec.Type == DatadogSLOTypeMetric && spec.TimeSlice != nil { + errs = append(errs, fmt.Errorf("spec.TimeSlice must not be defined when spec.Type is metric")) + } + if spec.Type == DatadogSLOTypeMonitor && spec.TimeSlice != nil { + errs = append(errs, fmt.Errorf("spec.TimeSlice must not be defined when spec.Type is monitor")) + } + if spec.Type == DatadogSLOTypeTimeSlice && spec.Query != nil { + errs = append(errs, fmt.Errorf("spec.Query must not be defined when spec.Type is time_slice")) + } + if spec.Type == DatadogSLOTypeTimeSlice && len(spec.MonitorIDs) > 0 { + errs = append(errs, fmt.Errorf("spec.MonitorIDs must not be defined when spec.Type is time_slice")) + } + if spec.TargetThreshold.AsApproximateFloat64() <= 0 || spec.TargetThreshold.AsApproximateFloat64() >= 100 { errs = append(errs, fmt.Errorf("spec.TargetThreshold must be greater than 0 and less than 100")) } diff --git a/api/datadoghq/v1alpha1/datadogslo_validation_test.go b/api/datadoghq/v1alpha1/datadogslo_validation_test.go index 267647cf2..14a06cf36 100644 --- a/api/datadoghq/v1alpha1/datadogslo_validation_test.go +++ b/api/datadoghq/v1alpha1/datadogslo_validation_test.go @@ -83,7 +83,7 @@ func TestIsValidDatadogSLO(t *testing.T) { TargetThreshold: resource.MustParse("99.99"), Timeframe: DatadogSLOTimeFrame30d, }, - expected: errors.New("spec.Type must be one of the values: monitor or metric"), + expected: errors.New("spec.Type must be one of the values: monitor, metric, or time_slice"), }, { name: "Missing Threshold and Timeframe", @@ -157,6 +157,116 @@ func TestIsValidDatadogSLO(t *testing.T) { }, expected: errors.New("spec.Timeframe must be defined as one of the values: 7d, 30d, or 90d"), }, + { + name: "Valid time_slice spec", + spec: &DatadogSLOSpec{ + Name: "TimeSliceSLO", + Type: DatadogSLOTypeTimeSlice, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: nil, + }, + { + name: "Missing TimeSlice when type is time_slice", + spec: &DatadogSLOSpec{ + Name: "TimeSliceSLO", + Type: DatadogSLOTypeTimeSlice, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.TimeSlice must be defined when spec.Type is time_slice"), + }, + { + name: "Empty query in time_slice", + spec: &DatadogSLOSpec{ + Name: "TimeSliceSLO", + Type: DatadogSLOTypeTimeSlice, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.TimeSlice.Query must be defined"), + }, + { + name: "time_slice type with Query set is invalid", + spec: &DatadogSLOSpec{ + Name: "TimeSliceSLO", + Type: DatadogSLOTypeTimeSlice, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + Query: &DatadogSLOQuery{ + Numerator: "good", + Denominator: "total", + }, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.Query must not be defined when spec.Type is time_slice"), + }, + { + name: "time_slice type with MonitorIDs set is invalid", + spec: &DatadogSLOSpec{ + Name: "TimeSliceSLO", + Type: DatadogSLOTypeTimeSlice, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + MonitorIDs: []int64{12345}, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.MonitorIDs must not be defined when spec.Type is time_slice"), + }, + { + name: "metric type with TimeSlice set is invalid", + spec: &DatadogSLOSpec{ + Name: "MySLO", + Type: DatadogSLOTypeMetric, + Query: &DatadogSLOQuery{ + Numerator: "good", + Denominator: "total", + }, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.TimeSlice must not be defined when spec.Type is metric"), + }, + { + name: "monitor type with TimeSlice set is invalid", + spec: &DatadogSLOSpec{ + Name: "MySLO", + Type: DatadogSLOTypeMonitor, + MonitorIDs: []int64{12345}, + TimeSlice: &DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + TargetThreshold: resource.MustParse("97"), + Timeframe: DatadogSLOTimeFrame7d, + }, + expected: errors.New("spec.TimeSlice must not be defined when spec.Type is monitor"), + }, } for _, tt := range tests { diff --git a/api/datadoghq/v1alpha1/zz_generated.deepcopy.go b/api/datadoghq/v1alpha1/zz_generated.deepcopy.go index bd1f58191..a5a39242c 100644 --- a/api/datadoghq/v1alpha1/zz_generated.deepcopy.go +++ b/api/datadoghq/v1alpha1/zz_generated.deepcopy.go @@ -1431,6 +1431,11 @@ func (in *DatadogSLOSpec) DeepCopyInto(out *DatadogSLOSpec) { *out = new(DatadogSLOQuery) **out = **in } + if in.TimeSlice != nil { + in, out := &in.TimeSlice, &out.TimeSlice + *out = new(DatadogSLOTimeSlice) + (*in).DeepCopyInto(*out) + } out.TargetThreshold = in.TargetThreshold.DeepCopy() if in.WarningThreshold != nil { in, out := &in.WarningThreshold, &out.WarningThreshold @@ -1484,6 +1489,22 @@ func (in *DatadogSLOStatus) DeepCopy() *DatadogSLOStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DatadogSLOTimeSlice) DeepCopyInto(out *DatadogSLOTimeSlice) { + *out = *in + out.Threshold = in.Threshold.DeepCopy() +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatadogSLOTimeSlice. +func (in *DatadogSLOTimeSlice) DeepCopy() *DatadogSLOTimeSlice { + if in == nil { + return nil + } + out := new(DatadogSLOTimeSlice) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProfileAffinity) DeepCopyInto(out *ProfileAffinity) { *out = *in diff --git a/api/datadoghq/v1alpha1/zz_generated.openapi.go b/api/datadoghq/v1alpha1/zz_generated.openapi.go index 768486a6e..15917854a 100644 --- a/api/datadoghq/v1alpha1/zz_generated.openapi.go +++ b/api/datadoghq/v1alpha1/zz_generated.openapi.go @@ -52,6 +52,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOQuery": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOQuery(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOSpec": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOSpec(ref), "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOStatus": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOStatus(ref), + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOTimeSlice": schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOTimeSlice(ref), } } @@ -2001,6 +2002,12 @@ func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOSpec(ref common.Re Ref: ref("github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOQuery"), }, }, + "timeSlice": { + SchemaProps: spec.SchemaProps{ + Description: "TimeSlice defines the SLI specification for a time_slice SLO. Required if type is time_slice. It specifies a metric query and a comparator/threshold that determines what counts as good uptime.", + Ref: ref("github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOTimeSlice"), + }, + }, "type": { SchemaProps: spec.SchemaProps{ Description: "Type is the type of the service level objective.", @@ -2040,7 +2047,7 @@ func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOSpec(ref common.Re }, }, Dependencies: []string{ - "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOControllerOptions", "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOQuery", "k8s.io/apimachinery/pkg/api/resource.Quantity"}, + "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOControllerOptions", "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOQuery", "github.com/DataDog/datadog-operator/api/datadoghq/v1alpha1.DatadogSLOTimeSlice", "k8s.io/apimachinery/pkg/api/resource.Quantity"}, } } @@ -2120,3 +2127,41 @@ func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOStatus(ref common. "k8s.io/apimachinery/pkg/apis/meta/v1.Condition", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"}, } } + +func schema_datadog_operator_api_datadoghq_v1alpha1_DatadogSLOTimeSlice(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "DatadogSLOTimeSlice defines the SLI specification for a time_slice SLO. It specifies a metric query and a comparator/threshold that determines what counts as good uptime. The operator automatically wraps the query into the formula and named query structure required by the Datadog API.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "query": { + SchemaProps: spec.SchemaProps{ + Description: "Query is a Datadog metric query string that produces the SLI value.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "comparator": { + SchemaProps: spec.SchemaProps{ + Description: "Comparator is the comparison operator used to compare the SLI value to the threshold.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "threshold": { + SchemaProps: spec.SchemaProps{ + Description: "Threshold is the value against which the SLI is compared using the comparator to determine if a time slice is good or bad.", + Ref: ref("k8s.io/apimachinery/pkg/api/resource.Quantity"), + }, + }, + }, + Required: []string{"query", "comparator", "threshold"}, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/api/resource.Quantity"}, + } +} diff --git a/config/crd/bases/v1/datadoghq.com_datadogslos.yaml b/config/crd/bases/v1/datadoghq.com_datadogslos.yaml index 6d1ba3cb6..41a703aca 100644 --- a/config/crd/bases/v1/datadoghq.com_datadogslos.yaml +++ b/config/crd/bases/v1/datadoghq.com_datadogslos.yaml @@ -112,6 +112,42 @@ spec: description: TargetThreshold is the target threshold such that when the service level indicator is above this threshold over the given timeframe, the objective is being met. pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true + timeSlice: + description: |- + TimeSlice defines the SLI specification for a time_slice SLO. Required if type is time_slice. + It specifies a metric query and a comparator/threshold that determines what counts as good uptime. + properties: + comparator: + allOf: + - enum: + - '>' + - '>=' + - < + - <= + - enum: + - '>' + - '>=' + - < + - <= + description: Comparator is the comparison operator used to compare the SLI value to the threshold. + type: string + query: + description: Query is a Datadog metric query string that produces the SLI value. + type: string + threshold: + anyOf: + - type: integer + - type: string + description: |- + Threshold is the value against which the SLI is compared using the comparator to determine + if a time slice is good or bad. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - comparator + - query + - threshold + type: object timeframe: description: The SLO time window options. type: string diff --git a/config/crd/bases/v1/datadoghq.com_datadogslos_v1alpha1.json b/config/crd/bases/v1/datadoghq.com_datadogslos_v1alpha1.json index bdc8e03ac..b10ac22d8 100644 --- a/config/crd/bases/v1/datadoghq.com_datadogslos_v1alpha1.json +++ b/config/crd/bases/v1/datadoghq.com_datadogslos_v1alpha1.json @@ -92,6 +92,57 @@ "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", "x-kubernetes-int-or-string": true }, + "timeSlice": { + "additionalProperties": false, + "description": "TimeSlice defines the SLI specification for a time_slice SLO. Required if type is time_slice.\nIt specifies a metric query and a comparator/threshold that determines what counts as good uptime.", + "properties": { + "comparator": { + "allOf": [ + { + "enum": [ + "\u003e", + "\u003e=", + "\u003c", + "\u003c=" + ] + }, + { + "enum": [ + "\u003e", + "\u003e=", + "\u003c", + "\u003c=" + ] + } + ], + "description": "Comparator is the comparison operator used to compare the SLI value to the threshold.", + "type": "string" + }, + "query": { + "description": "Query is a Datadog metric query string that produces the SLI value.", + "type": "string" + }, + "threshold": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "description": "Threshold is the value against which the SLI is compared using the comparator to determine\nif a time slice is good or bad.", + "pattern": "^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + "x-kubernetes-int-or-string": true + } + }, + "required": [ + "comparator", + "query", + "threshold" + ], + "type": "object" + }, "timeframe": { "description": "The SLO time window options.", "type": "string" diff --git a/internal/controller/datadogslo/slo.go b/internal/controller/datadogslo/slo.go index 0bd309214..7c8bd84fb 100644 --- a/internal/controller/datadogslo/slo.go +++ b/internal/controller/datadogslo/slo.go @@ -40,6 +40,9 @@ func buildSLO(crdSLO *v1alpha1.DatadogSLO) (*datadogV1.ServiceLevelObjectiveRequ sloReq.SetMonitorIds(crdSLO.Spec.MonitorIDs) sloReq.SetGroups(crdSLO.Spec.Groups) } + if crdSLO.Spec.Type == v1alpha1.DatadogSLOTypeTimeSlice { + sloReq.SetSliSpecification(buildSliSpecification(crdSLO.Spec.TimeSlice)) + } } // Used for SLO updates @@ -61,11 +64,44 @@ func buildSLO(crdSLO *v1alpha1.DatadogSLO) (*datadogV1.ServiceLevelObjectiveRequ slo.SetMonitorIds(crdSLO.Spec.MonitorIDs) slo.SetGroups(crdSLO.Spec.Groups) } + if crdSLO.Spec.Type == v1alpha1.DatadogSLOTypeTimeSlice { + slo.SetSliSpecification(buildSliSpecification(crdSLO.Spec.TimeSlice)) + } } return sloReq, slo } +func buildSliSpecification(ts *v1alpha1.DatadogSLOTimeSlice) datadogV1.SLOSliSpec { + const queryName = "query1" + + formulas := []datadogV1.SLOFormula{ + *datadogV1.NewSLOFormula(queryName), + } + + queries := []datadogV1.SLODataSourceQueryDefinition{ + datadogV1.FormulaAndFunctionMetricQueryDefinitionAsSLODataSourceQueryDefinition( + datadogV1.NewFormulaAndFunctionMetricQueryDefinition( + datadogV1.FORMULAANDFUNCTIONMETRICDATASOURCE_METRICS, + queryName, + ts.Query, + ), + ), + } + + threshold, _ := strconv.ParseFloat(ts.Threshold.AsDec().String(), 64) + + condition := datadogV1.NewSLOTimeSliceCondition( + datadogV1.SLOTimeSliceComparator(ts.Comparator), + *datadogV1.NewSLOTimeSliceQuery(formulas, queries), + threshold, + ) + + return datadogV1.SLOTimeSliceSpecAsSLOSliSpec( + datadogV1.NewSLOTimeSliceSpec(*condition), + ) +} + func buildThreshold(sloSpec v1alpha1.DatadogSLOSpec) []datadogV1.SLOThreshold { // Convert DatadogSLOSpec Timeframe, TargetThreshold, and WarningThreshold to datadogV1.SLOThreshold // (returned as a single-item list) for backwards compatibility. diff --git a/internal/controller/datadogslo/slo_test.go b/internal/controller/datadogslo/slo_test.go index c3c4c7676..1a8d375dc 100644 --- a/internal/controller/datadogslo/slo_test.go +++ b/internal/controller/datadogslo/slo_test.go @@ -73,3 +73,193 @@ func float64Ptr(f float64) *float64 { func ptrResourceQuantity(n resource.Quantity) *resource.Quantity { return &n } + +func strPtr(s string) *string { + return &s +} + +func Test_buildSLO(t *testing.T) { + tests := []struct { + name string + crdSLO *v1alpha1.DatadogSLO + validateRequest func(t *testing.T, req *datadogV1.ServiceLevelObjectiveRequest) + validateUpdateObj func(t *testing.T, slo *datadogV1.ServiceLevelObjective) + }{ + { + name: "metric SLO sets query, no sli_specification", + crdSLO: &v1alpha1.DatadogSLO{ + Spec: v1alpha1.DatadogSLOSpec{ + Name: "metric-slo", + Description: strPtr("a metric SLO"), + Type: v1alpha1.DatadogSLOTypeMetric, + Query: &v1alpha1.DatadogSLOQuery{ + Numerator: "sum:good.events{*}.as_count()", + Denominator: "sum:total.events{*}.as_count()", + }, + Tags: []string{"env:prod"}, + TargetThreshold: resource.MustParse("99.9"), + Timeframe: v1alpha1.DatadogSLOTimeFrame7d, + }, + }, + validateRequest: func(t *testing.T, req *datadogV1.ServiceLevelObjectiveRequest) { + assert.Equal(t, datadogV1.SLOType("metric"), req.GetType()) + assert.Equal(t, "metric-slo", req.GetName()) + assert.Equal(t, "a metric SLO", req.GetDescription()) + assert.Equal(t, []string{"env:prod"}, req.GetTags()) + + query := req.GetQuery() + assert.Equal(t, "sum:good.events{*}.as_count()", query.Numerator) + assert.Equal(t, "sum:total.events{*}.as_count()", query.Denominator) + + _, hasSliSpec := req.GetSliSpecificationOk() + assert.False(t, hasSliSpec) + }, + validateUpdateObj: func(t *testing.T, slo *datadogV1.ServiceLevelObjective) { + assert.Equal(t, datadogV1.SLOType("metric"), slo.GetType()) + query := slo.GetQuery() + assert.Equal(t, "sum:good.events{*}.as_count()", query.Numerator) + }, + }, + { + name: "monitor SLO sets monitor IDs and groups, no query or sli_specification", + crdSLO: &v1alpha1.DatadogSLO{ + Spec: v1alpha1.DatadogSLOSpec{ + Name: "monitor-slo", + Type: v1alpha1.DatadogSLOTypeMonitor, + MonitorIDs: []int64{12345, 67890}, + Groups: []string{"env:prod"}, + Tags: []string{"team:backend"}, + TargetThreshold: resource.MustParse("97"), + Timeframe: v1alpha1.DatadogSLOTimeFrame30d, + }, + }, + validateRequest: func(t *testing.T, req *datadogV1.ServiceLevelObjectiveRequest) { + assert.Equal(t, datadogV1.SLOType("monitor"), req.GetType()) + assert.Equal(t, []int64{12345, 67890}, req.GetMonitorIds()) + assert.Equal(t, []string{"env:prod"}, req.GetGroups()) + + _, hasQuery := req.GetQueryOk() + assert.False(t, hasQuery) + + _, hasSliSpec := req.GetSliSpecificationOk() + assert.False(t, hasSliSpec) + }, + validateUpdateObj: func(t *testing.T, slo *datadogV1.ServiceLevelObjective) { + assert.Equal(t, datadogV1.SLOType("monitor"), slo.GetType()) + assert.Equal(t, []int64{12345, 67890}, slo.GetMonitorIds()) + }, + }, + { + name: "time_slice SLO sets sli_specification with auto-generated formula and named query", + crdSLO: &v1alpha1.DatadogSLO{ + Spec: v1alpha1.DatadogSLOSpec{ + Name: "timeslice-slo", + Description: strPtr("a time slice SLO"), + Type: v1alpha1.DatadogSLOTypeTimeSlice, + TimeSlice: &v1alpha1.DatadogSLOTimeSlice{ + Query: "trace.servlet.request{env:prod}", + Comparator: v1alpha1.DatadogSLOTimeSliceComparatorGreater, + Threshold: resource.MustParse("5"), + }, + Tags: []string{"env:prod"}, + TargetThreshold: resource.MustParse("97"), + Timeframe: v1alpha1.DatadogSLOTimeFrame7d, + }, + }, + validateRequest: func(t *testing.T, req *datadogV1.ServiceLevelObjectiveRequest) { + assert.Equal(t, datadogV1.SLOType("time_slice"), req.GetType()) + assert.Equal(t, "timeslice-slo", req.GetName()) + assert.Equal(t, "a time slice SLO", req.GetDescription()) + + _, hasQuery := req.GetQueryOk() + assert.False(t, hasQuery) + + sliSpec, hasSliSpec := req.GetSliSpecificationOk() + assert.True(t, hasSliSpec) + + timeSliceSpec := sliSpec.SLOTimeSliceSpec + assert.NotNil(t, timeSliceSpec) + + condition := timeSliceSpec.TimeSlice + assert.Equal(t, datadogV1.SLOTimeSliceComparator(">"), condition.Comparator) + assert.Equal(t, float64(5), condition.Threshold) + + // Verify auto-generated formula references the auto-generated query name + assert.Len(t, condition.Query.Formulas, 1) + assert.Equal(t, "query1", condition.Query.Formulas[0].Formula) + + // Verify single auto-generated named query with hardcoded metrics data source + assert.Len(t, condition.Query.Queries, 1) + metricQuery := condition.Query.Queries[0].FormulaAndFunctionMetricQueryDefinition + assert.NotNil(t, metricQuery) + assert.Equal(t, datadogV1.FORMULAANDFUNCTIONMETRICDATASOURCE_METRICS, metricQuery.DataSource) + assert.Equal(t, "query1", metricQuery.Name) + assert.Equal(t, "trace.servlet.request{env:prod}", metricQuery.Query) + }, + validateUpdateObj: func(t *testing.T, slo *datadogV1.ServiceLevelObjective) { + assert.Equal(t, datadogV1.SLOType("time_slice"), slo.GetType()) + + sliSpec, hasSliSpec := slo.GetSliSpecificationOk() + assert.True(t, hasSliSpec) + assert.NotNil(t, sliSpec.SLOTimeSliceSpec) + + condition := sliSpec.SLOTimeSliceSpec.TimeSlice + assert.Equal(t, datadogV1.SLOTimeSliceComparator(">"), condition.Comparator) + assert.Equal(t, float64(5), condition.Threshold) + assert.Equal(t, "trace.servlet.request{env:prod}", condition.Query.Queries[0].FormulaAndFunctionMetricQueryDefinition.Query) + }, + }, + { + name: "time_slice SLO with less-equal comparator and fractional threshold", + crdSLO: &v1alpha1.DatadogSLO{ + Spec: v1alpha1.DatadogSLOSpec{ + Name: "latency-timeslice", + Type: v1alpha1.DatadogSLOTypeTimeSlice, + TimeSlice: &v1alpha1.DatadogSLOTimeSlice{ + Query: "avg:trace.servlet.request.duration{service:data-model-manager}", + Comparator: v1alpha1.DatadogSLOTimeSliceComparatorLessEqual, + Threshold: resource.MustParse("0.5"), + }, + TargetThreshold: resource.MustParse("95"), + Timeframe: v1alpha1.DatadogSLOTimeFrame30d, + }, + }, + validateRequest: func(t *testing.T, req *datadogV1.ServiceLevelObjectiveRequest) { + sliSpec := req.GetSliSpecification() + condition := sliSpec.SLOTimeSliceSpec.TimeSlice + + assert.Equal(t, datadogV1.SLOTimeSliceComparator("<="), condition.Comparator) + assert.Equal(t, 0.5, condition.Threshold) + + assert.Len(t, condition.Query.Formulas, 1) + assert.Equal(t, "query1", condition.Query.Formulas[0].Formula) + + assert.Len(t, condition.Query.Queries, 1) + assert.Equal(t, "avg:trace.servlet.request.duration{service:data-model-manager}", + condition.Query.Queries[0].FormulaAndFunctionMetricQueryDefinition.Query) + }, + validateUpdateObj: func(t *testing.T, slo *datadogV1.ServiceLevelObjective) { + sliSpec := slo.GetSliSpecification() + condition := sliSpec.SLOTimeSliceSpec.TimeSlice + assert.Equal(t, datadogV1.SLOTimeSliceComparator("<="), condition.Comparator) + assert.Equal(t, 0.5, condition.Threshold) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, slo := buildSLO(tt.crdSLO) + + assert.NotNil(t, req) + assert.NotNil(t, slo) + + if tt.validateRequest != nil { + tt.validateRequest(t, req) + } + if tt.validateUpdateObj != nil { + tt.validateUpdateObj(t, slo) + } + }) + } +}