Skip to content
This repository has been archived by the owner on Jan 12, 2024. It is now read-only.

feat: Support units in Data Explorer and Custom Charting tiles #939

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 44 additions & 60 deletions internal/dynatrace/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,47 +74,48 @@ type ManagementZoneEntry struct {
}

type Tile struct {
Name string `json:"name"`
TileType string `json:"tileType"`
Configured bool `json:"configured"`
Query string `json:"query,omitempty"`
Type string `json:"type,omitempty"`
CustomName string `json:"customName,omitempty"`
Markdown string `json:"markdown,omitempty"`
ChartVisible bool `json:"chartVisible,omitempty"`
Bounds Bounds `json:"bounds"`
TileFilter TileFilter `json:"tileFilter"`
Queries []DataExplorerQuery `json:"queries,omitempty"`
AssignedEntities []string `json:"assignedEntities,omitempty"`
ExcludeMaintenanceWindows bool `json:"excludeMaintenanceWindows,omitempty"`
FilterConfig *FilterConfig `json:"filterConfig,omitempty"`
VisualConfig *VisualConfig `json:"visualConfig,omitempty"`
MetricExpressions []string `json:"metricExpressions,omitempty"`
}

// VisualConfig is the visual configuration for a dashboard tile.
type VisualConfig struct {
Type string `json:"type,omitempty"`
Thresholds []Threshold `json:"thresholds,omitempty"`
Rules []VisualConfigRule `json:"rules,omitempty"`
}

// SingleValueVisualConfigType is the single value visualization type for VisualConfigs
const SingleValueVisualConfigType = "SINGLE_VALUE"

// VisualConfigRule is a rule for the visual configuration.
type VisualConfigRule struct {
Name string `json:"name"`
TileType string `json:"tileType"`
Configured bool `json:"configured"`
Query string `json:"query,omitempty"`
Type string `json:"type,omitempty"`
CustomName string `json:"customName,omitempty"`
Markdown string `json:"markdown,omitempty"`
ChartVisible bool `json:"chartVisible,omitempty"`
Bounds Bounds `json:"bounds"`
TileFilter TileFilter `json:"tileFilter"`
Queries []DataExplorerQuery `json:"queries,omitempty"`
AssignedEntities []string `json:"assignedEntities,omitempty"`
ExcludeMaintenanceWindows bool `json:"excludeMaintenanceWindows,omitempty"`
FilterConfig *FilterConfig `json:"filterConfig,omitempty"`
VisualConfig *VisualizationConfiguration `json:"visualConfig,omitempty"`
MetricExpressions []string `json:"metricExpressions,omitempty"`
}

// VisualizationConfiguration is the visual configuration for a dashboard tile.
type VisualizationConfiguration struct {
Type string `json:"type,omitempty"`
Thresholds []VisualizationThreshold `json:"thresholds,omitempty"`
Rules []VisualizationRule `json:"rules,omitempty"`
}

// SingleValueVisualizationConfigurationType is the single value visualization type for VisualConfigs
const SingleValueVisualizationConfigurationType = "SINGLE_VALUE"

// VisualizationRule is a rule for the visual configuration.
type VisualizationRule struct {
UnitTransform string `json:"unitTransform,omitempty"`
Matcher string `json:"matcher,omitempty"`
}

// Threshold is a threshold configuration for a Data Explorer tile.
type Threshold struct {
Visible bool `json:"visible"`
Rules []ThresholdRule `json:"rules,omitempty"`
// VisualizationThreshold is a threshold configuration for a Data Explorer tile.
type VisualizationThreshold struct {
Visible bool `json:"visible"`
Rules []VisualizationThresholdRule `json:"rules,omitempty"`
}

// ThresholdRule is a rule for a threshold.
type ThresholdRule struct {
// VisualizationThresholdRule is a rule for a threshold.
type VisualizationThresholdRule struct {
Value *float64 `json:"value,omitempty"`
Color string `json:"color"`
}
Expand All @@ -133,26 +134,8 @@ type TileFilter struct {

// DataExplorerQuery Query Definition for DATA_EXPLORER dashboard tile
type DataExplorerQuery struct {
ID string `json:"id"`
Metric string `json:"metric"`
SpaceAggregation string `json:"spaceAggregation,omitempty"`
TimeAggregation string `json:"timeAggregation"`
SplitBy []string `json:"splitBy"`
FilterBy *DataExplorerFilter `json:"filterBy,omitempty"`
}

type DataExplorerFilter struct {
Filter string `json:"filter,omitempty"`
FilterType string `json:"filterType,omitempty"`
FilterOperator string `json:"filterOperator,omitempty"`
EntityAttribute string `json:"entityAttribute,omitempty"`
NestedFilters []DataExplorerFilter `json:"nestedFilters"`
Criteria []DataExplorerCriterion `json:"criteria"`
}

type DataExplorerCriterion struct {
Value string `json:"value"`
Evaluator string `json:"evaluator"`
ID string `json:"id"`
Enabled bool `json:"enabled"`
}

type FilterConfig struct {
Expand Down Expand Up @@ -210,10 +193,11 @@ FiltersPerEntityType struct {
*/

type ChartConfig struct {
LegendShown bool `json:"legendShown"`
Type string `json:"type"`
Series []Series `json:"series"`
ResultMetadata ResultMetadata `json:"resultMetadata"`
LegendShown bool `json:"legendShown"`
Type string `json:"type"`
Series []Series `json:"series"`
ResultMetadata ResultMetadata `json:"resultMetadata"`
LeftAxisCustomUnit string `json:"leftAxisCustomUnit,omitempty"`
}

type Series struct {
Expand Down
52 changes: 52 additions & 0 deletions internal/dynatrace/metrics_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,55 @@ func (p *RetryForSingleValueMetricsProcessingDecorator) modifyQuery(ctx context.

return metrics.NewQuery("("+metricSelector+"):fold()", existingQuery.GetEntitySelector(), existingQuery.GetResolution(), existingQuery.GetMZSelector())
}

// ConvertUnitMetricsProcessingDecorator decorates MetricsProcessing by converting the unit of the results.
type ConvertUnitMetricsProcessingDecorator struct {
metricsClient MetricsClientInterface
unitsClient MetricsUnitsClientInterface
targetUnitID string
metricsProcessing MetricsProcessingInterface
}

// NewConvertUnitMetricsProcessingDecorator creates a new ConvertUnitMetricsProcessingDecorator using the specified client interfaces, target unit ID and underlying metrics processing interface.
func NewConvertUnitMetricsProcessingDecorator(metricsClient MetricsClientInterface,
unitsClient MetricsUnitsClientInterface,
targetUnitID string,
metricsProcessing MetricsProcessingInterface) *ConvertUnitMetricsProcessingDecorator {
return &ConvertUnitMetricsProcessingDecorator{
metricsClient: metricsClient,
unitsClient: unitsClient,
targetUnitID: targetUnitID,
metricsProcessing: metricsProcessing,
}
}

// ProcessRequest queries and processes metrics using the specified request.
func (p *ConvertUnitMetricsProcessingDecorator) ProcessRequest(ctx context.Context, request MetricsClientQueryRequest) (*MetricsProcessingResults, error) {
result, err := p.metricsProcessing.ProcessRequest(ctx, request)

if err != nil {
return nil, err
}

if p.targetUnitID == "" {
return result, nil
}

metricSelector := result.request.query.GetMetricSelector()
metricDefinition, err := p.metricsClient.GetMetricDefinitionByID(ctx, metricSelector)
if err != nil {
return nil, err
}

sourceUnitID := metricDefinition.Unit
convertedResults := make([]MetricsProcessingResult, len(result.Results()))
for i, r := range result.results {
v, err := p.unitsClient.Convert(ctx, NewMetricsUnitsClientConvertRequest(sourceUnitID, r.value, p.targetUnitID))
if err != nil {
return nil, err
}

convertedResults[i] = newMetricsProcessingResult(r.Name(), v)
}
return newMetricsProcessingResults(result.Request(), convertedResults, result.Warnings()), nil
}
81 changes: 81 additions & 0 deletions internal/dynatrace/metrics_units_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dynatrace

import (
"context"
"encoding/json"
"net/url"
"strconv"
)

// MetricsUnitsPath is the base endpoint for Metrics Units API
const MetricsUnitsPath = "/api/v2/units"

// UnitConversionResult is the result of a conversion.
type UnitConversionResult struct {
UnitID string `json:"unitId"`
ResultValue float64 `json:"resultValue"`
}

const (
valueKey = "value"
targetUnitKey = "targetUnit"
)

// MetricsUnitsClientConvertRequest encapsulates the request for the MetricsUnitsClient's Convert method.
type MetricsUnitsClientConvertRequest struct {
sourceUnitID string
value float64
targetUnitID string
}

// NewMetricsUnitsClientConvertRequest creates a new MetricsUnitsClientConvertRequest.
func NewMetricsUnitsClientConvertRequest(sourceUnitID string, value float64, targetUnitID string) MetricsUnitsClientConvertRequest {
return MetricsUnitsClientConvertRequest{
sourceUnitID: sourceUnitID,
value: value,
targetUnitID: targetUnitID,
}
}

// RequestString encodes MetricsUnitsClientConvertRequest into a request string.
func (q *MetricsUnitsClientConvertRequest) RequestString() string {
queryParameters := newQueryParameters()
queryParameters.add(valueKey, strconv.FormatFloat(q.value, 'f', -1, 64))
queryParameters.add(targetUnitKey, q.targetUnitID)

return MetricsUnitsPath + "/" + url.PathEscape(q.sourceUnitID) + "/convert?" + queryParameters.encode()
}

// MetricsUnitsClientInterface defines functions for the Dynatrace Metrics Units endpoint.
type MetricsUnitsClientInterface interface {
// Convert converts a value between the specified units.
Convert(ctx context.Context, request MetricsUnitsClientConvertRequest) (float64, error)
}

// MetricsUnitsClient is a client for interacting with Dynatrace Metrics Units endpoint.
type MetricsUnitsClient struct {
client ClientInterface
}

// NewMetricsUnitsClient creates a new MetricsUnitsClient
func NewMetricsUnitsClient(client ClientInterface) *MetricsUnitsClient {
return &MetricsUnitsClient{
client: client,
}
}

// Convert converts a value between the specified units.
func (c *MetricsUnitsClient) Convert(ctx context.Context, request MetricsUnitsClientConvertRequest) (float64, error) {
body, err := c.client.Get(ctx, request.RequestString())
if err != nil {
return 0, err
}

var result UnitConversionResult
err = json.Unmarshal(body, &result)
if err != nil {
return 0, err
}

return result.ResultValue, nil
}
21 changes: 11 additions & 10 deletions internal/sli/dashboard/custom_charting_tile_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,7 @@ func (p *CustomChartingTileProcessing) Process(ctx context.Context, tile *dynatr
}

sloDefinitionParsingResult, err := parseSLODefinition(tile.FilterConfig.CustomName)
var sloDefError *sloDefinitionError
if errors.As(err, &sloDefError) {
return []TileResult{newFailedTileResultFromError(sloDefError.sliNameOrTileTitle(), "Custom charting tile title parsing error", err)}
}

if sloDefinitionParsingResult.exclude {
if (err == nil) && (sloDefinitionParsingResult.exclude) {
log.WithField("tile.FilterConfig.CustomName", tile.FilterConfig.CustomName).Debug("Tile excluded as name includes exclude=true")
return nil
}
Expand All @@ -59,24 +54,30 @@ func (p *CustomChartingTileProcessing) Process(ctx context.Context, tile *dynatr
return nil
}

if err != nil {
return []TileResult{newFailedTileResultFromSLODefinition(sloDefinition, "Custom charting tile title parsing error: "+err.Error())}
}

// get the tile specific management zone filter that might be needed by different tile processors
// Check for tile management zone filter - this would overwrite the dashboardManagementZoneFilter
tileManagementZoneFilter := NewManagementZoneFilter(dashboardFilter, tile.TileFilter.ManagementZone)

if len(tile.FilterConfig.ChartConfig.Series) != 1 {
chartConfig := tile.FilterConfig.ChartConfig
targetUnitID := chartConfig.LeftAxisCustomUnit
if len(chartConfig.Series) != 1 {
return []TileResult{newFailedTileResultFromSLODefinition(sloDefinition, "Custom charting tile must have exactly one series")}
}

return p.processSeries(ctx, sloDefinition, &tile.FilterConfig.ChartConfig.Series[0], tileManagementZoneFilter, tile.FilterConfig.FiltersPerEntityType)
return p.processSeries(ctx, sloDefinition, &chartConfig.Series[0], targetUnitID, tileManagementZoneFilter, tile.FilterConfig.FiltersPerEntityType)
}

func (p *CustomChartingTileProcessing) processSeries(ctx context.Context, sloDefinition keptnapi.SLO, series *dynatrace.Series, tileManagementZoneFilter *ManagementZoneFilter, filtersPerEntityType map[string]dynatrace.FilterMap) []TileResult {
func (p *CustomChartingTileProcessing) processSeries(ctx context.Context, sloDefinition keptnapi.SLO, series *dynatrace.Series, targetUnitID string, tileManagementZoneFilter *ManagementZoneFilter, filtersPerEntityType map[string]dynatrace.FilterMap) []TileResult {
metricsQuery, err := p.generateMetricQueryFromChartSeries(ctx, series, tileManagementZoneFilter, filtersPerEntityType)
if err != nil {
return []TileResult{newFailedTileResultFromSLODefinition(sloDefinition, "Custom charting tile could not be converted to a metric query: "+err.Error())}
}

return NewMetricsQueryProcessing(p.client).Process(ctx, sloDefinition, *metricsQuery, p.timeframe)
return NewMetricsQueryProcessing(p.client, targetUnitID).Process(ctx, sloDefinition, *metricsQuery, p.timeframe)
}

func (p *CustomChartingTileProcessing) generateMetricQueryFromChartSeries(ctx context.Context, series *dynatrace.Series, tileManagementZoneFilter *ManagementZoneFilter, filtersPerEntityType map[string]dynatrace.FilterMap) (*metrics.Query, error) {
Expand Down
Loading