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

Commit

Permalink
feat: Support units in Data Explorer and Custom Charting tiles (#939)
Browse files Browse the repository at this point in the history
* Add `MetricsUnitsClient`

Signed-off-by: Arthur Pitman <[email protected]>

* Add `ConvertUnitMetricsProcessingDecorator`

Signed-off-by: Arthur Pitman <[email protected]>

* Remove unneeded Data Explorer tile fields

Signed-off-by: Arthur Pitman <[email protected]>

* Rename several dashboard types

Signed-off-by: Arthur Pitman <[email protected]>

* Collect multiple Data Explorer configuration problems into a single error

Signed-off-by: Arthur Pitman <[email protected]>

* Enable units support in Data Explorer and Custom Charting

Signed-off-by: Arthur Pitman <[email protected]>

* Improve handling of multiple visualization rules for a single query

Signed-off-by: Arthur Pitman <[email protected]>

Signed-off-by: Arthur Pitman <[email protected]>
  • Loading branch information
arthurpitman authored Oct 20, 2022
1 parent d52df15 commit e32dd8a
Show file tree
Hide file tree
Showing 43 changed files with 3,224 additions and 224 deletions.
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

0 comments on commit e32dd8a

Please sign in to comment.