Skip to content

Commit

Permalink
Expose stats' aggregation method tokens and parsed thresholds publicly
Browse files Browse the repository at this point in the history
This commit makes some minor modifications to the `stats` package API.
Namely, it makes `stats.token*` symbols public. It also makes
`stats.Threshold.parsed` public. These changes are made in order to
facilitate validation of thresholds from outside the `stats` package.
Having access to both the parsed Threshold, and the aggregation methods
symbols will allow comparing them and asserting their meaningfulness in
a context where we have typed metrics available.

ref #2330
  • Loading branch information
oleiade committed Jan 27, 2022
1 parent 4bfec5d commit fba43c5
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 64 deletions.
42 changes: 26 additions & 16 deletions stats/thresholds.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"

"go.k6.io/k6/lib/types"
Expand All @@ -40,8 +39,8 @@ type Threshold struct {
// AbortGracePeriod is a the minimum amount of time a test should be running before a failing
// this threshold will abort the test
AbortGracePeriod types.NullDuration
// parsed is the threshold expression parsed from the Source
parsed *thresholdExpression
// Parsed is the threshold expression Parsed from the Source
Parsed *thresholdExpression
}

func newThreshold(src string, abortOnFail bool, gracePeriod types.NullDuration) (*Threshold, error) {
Expand All @@ -54,46 +53,56 @@ func newThreshold(src string, abortOnFail bool, gracePeriod types.NullDuration)
Source: src,
AbortOnFail: abortOnFail,
AbortGracePeriod: gracePeriod,
parsed: parsedExpression,
Parsed: parsedExpression,
}, nil
}

func (t *Threshold) runNoTaint(sinks map[string]float64) (bool, error) {
// Because aggregation method can either be a static keyword ("count", "rate", etc...),
// or a parametric expression ("p(somefloatingpointvalue)"), we need to handle this
// case specifically. If we encounter the percentile aggregation method token,
// we recompute the whole "p(value)" expression in order to look for it in the
// sinks.
sinkKey := t.Parsed.AggregationMethod
if t.Parsed.AggregationMethod == TokenPercentile {
sinkKey = fmt.Sprintf("%s(%v)", TokenPercentile, t.Parsed.AggregationValue.Float64)
}

// Extract the sink value for the aggregation method used in the threshold
// expression
lhs, ok := sinks[t.parsed.AggregationMethod]
lhs, ok := sinks[sinkKey]
if !ok {
return false, fmt.Errorf("unable to apply threshold %s over metrics; reason: "+
"no metric supporting the %s aggregation method found",
t.Source,
t.parsed.AggregationMethod)
t.Parsed.AggregationMethod)
}

// Apply the threshold expression operator to the left and
// right hand side values
var passes bool
switch t.parsed.Operator {
switch t.Parsed.Operator {
case ">":
passes = lhs > t.parsed.Value
passes = lhs > t.Parsed.Value
case ">=":
passes = lhs >= t.parsed.Value
passes = lhs >= t.Parsed.Value
case "<=":
passes = lhs <= t.parsed.Value
passes = lhs <= t.Parsed.Value
case "<":
passes = lhs < t.parsed.Value
passes = lhs < t.Parsed.Value
case "==", "===":
// Considering a sink always maps to float64 values,
// strictly equal is equivalent to loosely equal
passes = lhs == t.parsed.Value
passes = lhs == t.Parsed.Value
case "!=":
passes = lhs != t.parsed.Value
passes = lhs != t.Parsed.Value
default:
// The parseThresholdExpression function should ensure that no invalid
// operator gets through, but let's protect our future selves anyhow.
return false, fmt.Errorf("unable to apply threshold %s over metrics; "+
"reason: %s is an invalid operator",
t.Source,
t.parsed.Operator,
t.Parsed.Operator,
)
}

Expand Down Expand Up @@ -218,11 +227,12 @@ func (ts *Thresholds) Run(sink Sink, duration time.Duration) (bool, error) {
// Parse the percentile thresholds and insert them in
// the sinks mapping.
for _, threshold := range ts.Thresholds {
if !strings.HasPrefix(threshold.parsed.AggregationMethod, "p(") {
if threshold.Parsed.AggregationMethod != TokenPercentile {
continue
}

ts.sinked[threshold.parsed.AggregationMethod] = sinkImpl.P(threshold.parsed.AggregationValue.Float64 / 100)
key := fmt.Sprintf("p(%v)", threshold.Parsed.AggregationValue)
ts.sinked[key] = sinkImpl.P(threshold.Parsed.AggregationValue.Float64 / 100)
}
case *RateSink:
ts.sinked["rate"] = float64(sinkImpl.Trues) / float64(sinkImpl.Total)
Expand Down
61 changes: 37 additions & 24 deletions stats/thresholds_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@
package stats

import (
"errors"
"fmt"
"strconv"
"strings"

"gopkg.in/guregu/null.v3"
)

// ErrThresholdParsing is returned by failing threshold parsing operations.
var ErrThresholdParsing = errors.New("parsing threshold expression failed")

// thresholdExpression holds the parsed result of a threshold expression,
// as described in: https://k6.io/docs/using-k6/thresholds/#threshold-syntax
type thresholdExpression struct {
Expand Down Expand Up @@ -73,17 +77,23 @@ func parseThresholdExpression(input string) (*thresholdExpression, error) {
// checks that the expression has the right format.
method, operator, value, err := scanThresholdExpression(input)
if err != nil {
return nil, fmt.Errorf("failed parsing threshold expression; reason: %w", err)
return nil, fmt.Errorf("%w '%s'; reason: %v", ErrThresholdParsing, input, err)
}

parsedMethod, parsedMethodValue, err := parseThresholdAggregationMethod(method)
if err != nil {
return nil, fmt.Errorf("failed parsing threshold expression's left hand side; reason: %w", err)
return nil, fmt.Errorf("%w '%s'; reason: %v", ErrThresholdParsing, input, err)
}

parsedValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return nil, fmt.Errorf("failed parsing threshold expresion's right hand side; reason: %w", err)
return nil, fmt.Errorf(
"%w '%s', right hand side could not be parsed as a "+
"64-bit precision floating point value; reason: %v",
ErrThresholdParsing,
input,
err,
)
}

condition := &thresholdExpression{
Expand Down Expand Up @@ -142,20 +152,23 @@ func scanThresholdExpression(input string) (string, string, string, error) {
}
}

return "", "", "", fmt.Errorf("malformed threshold expression")
return "", "", "", fmt.Errorf(
"no valid operator found in the threshold expression. " +
"valid operators are: <, <=, >, >=, ==, !=, ===",
)
}

// Define accepted threshold expression aggregation tokens
// Percentile token `p(..)` is accepted too but handled separately.
const (
tokenValue = "value"
tokenCount = "count"
tokenRate = "rate"
tokenAvg = "avg"
tokenMin = "min"
tokenMed = "med"
tokenMax = "max"
tokenPercentile = "p"
TokenValue = "value"
TokenCount = "count"
TokenRate = "rate"
TokenAvg = "avg"
TokenMin = "min"
TokenMed = "med"
TokenMax = "max"
TokenPercentile = "p"
)

// aggregationMethodTokens defines the list of aggregation method
Expand All @@ -165,14 +178,14 @@ const (
// Although declared as a `var`, being an array, it is effectively
// immutable and can be considered constant.
var aggregationMethodTokens = [8]string{ // nolint:gochecknoglobals
tokenValue,
tokenCount,
tokenRate,
tokenAvg,
tokenMin,
tokenMed,
tokenMax,
tokenPercentile,
TokenValue,
TokenCount,
TokenRate,
TokenAvg,
TokenMin,
TokenMed,
TokenMax,
TokenPercentile,
}

// parseThresholdMethod will parse a threshold condition expression's method.
Expand All @@ -186,21 +199,21 @@ func parseThresholdAggregationMethod(input string) (string, null.Float, error) {
// Percentile expressions being of the form p(value),
// they won't be matched here.
if m == input {
return input, null.Float{}, nil
return m, null.Float{}, nil
}
}

// Otherwise, attempt to parse a percentile expression
if strings.HasPrefix(input, tokenPercentile+"(") && strings.HasSuffix(input, ")") {
if strings.HasPrefix(input, TokenPercentile+"(") && strings.HasSuffix(input, ")") {
aggregationValue, err := strconv.ParseFloat(trimDelimited("p(", input, ")"), 64)
if err != nil {
return "", null.Float{}, fmt.Errorf("malformed percentile value; reason: %w", err)
}

return input, null.FloatFrom(aggregationValue), nil
return TokenPercentile, null.FloatFrom(aggregationValue), nil
}

return "", null.Float{}, fmt.Errorf("failed parsing method from expression")
return "", null.Float{}, fmt.Errorf("no valid aggregation method found in the threshold expression")
}

func trimDelimited(prefix, input, suffix string) string {
Expand Down
18 changes: 9 additions & 9 deletions stats/thresholds_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,63 +103,63 @@ func TestParseThresholdAggregationMethod(t *testing.T) {
{
name: "count method is parsed",
input: "count",
wantMethod: "count",
wantMethod: TokenCount,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "rate method is parsed",
input: "rate",
wantMethod: "rate",
wantMethod: TokenRate,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "value method is parsed",
input: "value",
wantMethod: "value",
wantMethod: TokenValue,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "avg method is parsed",
input: "avg",
wantMethod: "avg",
wantMethod: TokenAvg,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "min method is parsed",
input: "min",
wantMethod: "min",
wantMethod: TokenMin,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "max method is parsed",
input: "max",
wantMethod: "max",
wantMethod: TokenMax,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "med method is parsed",
input: "med",
wantMethod: "med",
wantMethod: TokenMed,
wantMethodValue: null.Float{},
wantErr: false,
},
{
name: "percentile method with integer value is parsed",
input: "p(99)",
wantMethod: "p(99)",
wantMethod: TokenPercentile,
wantMethodValue: null.FloatFrom(99),
wantErr: false,
},
{
name: "percentile method with floating point value is parsed",
input: "p(99.9)",
wantMethod: "p(99.9)",
wantMethod: TokenPercentile,
wantMethodValue: null.FloatFrom(99.9),
wantErr: false,
},
Expand Down
Loading

0 comments on commit fba43c5

Please sign in to comment.