Skip to content

Commit

Permalink
Expose stats' relevant threshold expression symbols 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.ThresholdExpression` and `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 Feb 3, 2022
1 parent 5d17ae8 commit 07bcc66
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 85 deletions.
32 changes: 16 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,46 @@ 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) {
// Extract the sink value for the aggregation method used in the threshold
// expression
lhs, ok := sinks[t.parsed.AggregationMethod]
lhs, ok := sinks[t.Parsed.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 +217,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(%g)", threshold.Parsed.AggregationValue.Float64)
ts.sinked[key] = sinkImpl.P(threshold.Parsed.AggregationValue.Float64 / 100)
}
case *RateSink:
ts.sinked["rate"] = float64(sinkImpl.Trues) / float64(sinkImpl.Total)
Expand Down
140 changes: 98 additions & 42 deletions stats/thresholds_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@
package stats

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

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

// thresholdExpression holds the parsed result of a threshold expression,
// 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 {
type ThresholdExpression struct {
// AggregationMethod holds the aggregation method parsed
// from the threshold expression. Possible values are described
// by `aggregationMethodTokens`.
Expand All @@ -49,6 +53,49 @@ type thresholdExpression struct {
Value float64
}

// NewThresholdExpression instantiates a new ThresholdExpression
func NewThresholdExpression(
aggregationMethod string,
aggregationValue null.Float,
operator string,
value float64,
) *ThresholdExpression {
return &ThresholdExpression{
AggregationMethod: aggregationMethod,
AggregationValue: aggregationValue,
Operator: operator,
Value: value,
}
}

// NewThresholdExpressionFrom parses a threshold expression from an input string
// and returns it as a ThresholdExpression pointer.
func NewThresholdExpressionFrom(input string) (*ThresholdExpression, error) {
return parseThresholdExpression(input)
}

// SinkKey computes the key used to index a ThresholdExpression in the engine's sinks.
//
// During execution, the engine "sinks" metrics into a internal mapping, so that
// thresholds can be tried against them. This method is a helper to normalize the
// sink the threshold expression should be applied to.
//
// Because a theshold expression's 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.
func (te *ThresholdExpression) SinkKey() string {
//
sinkKey := te.AggregationMethod
if te.AggregationMethod == TokenPercentile {
sinkKey = fmt.Sprintf("%s(%g)", TokenPercentile, te.AggregationValue.Float64)
}

return sinkKey
}

// parseThresholdAssertion parses a threshold condition expression,
// as defined in a JS script (for instance p(95)<1000), into a thresholdExpression
// instance.
Expand All @@ -68,25 +115,31 @@ type thresholdExpression struct {
// digit -> "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
// whitespace -> " "
// ```
func parseThresholdExpression(input string) (*thresholdExpression, error) {
func parseThresholdExpression(input string) (*ThresholdExpression, error) {
// Scanning makes no assumption on the underlying values, and only
// 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{
condition := &ThresholdExpression{
AggregationMethod: parsedMethod,
AggregationValue: parsedMethodValue,
Operator: operator,
Expand All @@ -98,13 +151,13 @@ func parseThresholdExpression(input string) (*thresholdExpression, error) {

// Define accepted threshold expression operators tokens
const (
tokenLessEqual = "<="
tokenLess = "<"
tokenGreaterEqual = ">="
tokenGreater = ">"
tokenStrictlyEqual = "==="
tokenLooselyEqual = "=="
tokenBangEqual = "!="
TokenLessEqual = "<="
TokenLess = "<"
TokenGreaterEqual = ">="
TokenGreater = ">"
TokenStrictlyEqual = "==="
TokenLooselyEqual = "=="
TokenBangEqual = "!="
)

// operatorTokens defines the list of operator-related tokens
Expand All @@ -119,13 +172,13 @@ const (
// Longer tokens with symbols in common with shorter ones must appear
// first in the list in order to be effectively matched.
var operatorTokens = [7]string{ // nolint:gochecknoglobals
tokenLessEqual,
tokenLess,
tokenGreaterEqual,
tokenGreater,
tokenStrictlyEqual,
tokenLooselyEqual,
tokenBangEqual,
TokenLessEqual,
TokenLess,
TokenGreaterEqual,
TokenGreater,
TokenStrictlyEqual,
TokenLooselyEqual,
TokenBangEqual,
}

// scanThresholdExpression scans a threshold condition expression of the
Expand All @@ -142,20 +195,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 +221,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 +242,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
Loading

0 comments on commit 07bcc66

Please sign in to comment.