diff --git a/config/sampler_config.go b/config/sampler_config.go index b35965c9c2..b1143fc466 100644 --- a/config/sampler_config.go +++ b/config/sampler_config.go @@ -2,6 +2,8 @@ package config import ( "fmt" + "strconv" + "strings" ) type DeterministicSamplerConfig struct { @@ -45,12 +47,343 @@ type RulesBasedSamplerCondition struct { Field string Operator string Value interface{} + Datatype string + Matches func(value any, exists bool) bool +} + +func (r *RulesBasedSamplerCondition) Init() error { + return r.setMatchesFunction() } func (r *RulesBasedSamplerCondition) String() string { return fmt.Sprintf("%+v", *r) } +func (r *RulesBasedSamplerCondition) setMatchesFunction() error { + switch r.Operator { + case "exists": + r.Matches = func(value any, exists bool) bool { + return exists + } + return nil + case "not-exists": + r.Matches = func(value any, exists bool) bool { + return !exists + } + return nil + case "!=", "=", ">", "<", "<=", ">=": + return setCompareOperators(r, r.Operator) + case "starts-with", "contains", "does-not-contain": + err := setMatchStringBasedOperators(r, r.Operator) + if err != nil { + return err + } + default: + return fmt.Errorf("unknown operator '%s'", r.Operator) + } + return nil +} + +func tryConvertToInt(v any) (int, bool) { + switch value := v.(type) { + case int: + return value, true + case int64: + return int(value), true + case float64: + return int(value), true + case bool: + return 0, false + case string: + n, err := strconv.Atoi(value) + if err == nil { + return n, true + } + return 0, false + default: + return 0, false + } +} + +func tryConvertToFloat(v any) (float64, bool) { + switch value := v.(type) { + case float64: + return value, true + case int: + return float64(value), true + case int64: + return float64(value), true + case bool: + return 0, false + case string: + n, err := strconv.ParseFloat(value, 64) + return n, err == nil + default: + return 0, false + } +} + +func tryConvertToString(v any) (string, bool) { + switch value := v.(type) { + case string: + return value, true + case int: + return strconv.Itoa(value), true + case int64: + return strconv.FormatInt(value, 10), true + case float64: + return strconv.FormatFloat(value, 'E', -1, 64), false + case bool: + return strconv.FormatBool(value), true + default: + return "", false + } +} + +func tryConvertToBool(v any) bool { + value, ok := tryConvertToString(v) + if !ok { + return false + } + str, err := strconv.ParseBool(value) + if err != nil { + return false + } + if str { + return true + } else { + return false + } +} + +func setCompareOperators(r *RulesBasedSamplerCondition, condition string) error { + switch r.Datatype { + case "string": + conditionValue, ok := tryConvertToString(r.Value) + if !ok { + return fmt.Errorf("could not convert %v to string", r.Value) + } + + // check if conditionValue and spanValue are not equal + switch condition { + case "!=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToString(spanValue); exists && ok { + return n != conditionValue + } + return false + } + return nil + case "=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToString(spanValue); exists && ok { + return n == conditionValue + } + return false + } + return nil + case ">": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToString(spanValue); exists && ok { + return n > conditionValue + } + return false + } + return nil + case "<": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToString(spanValue); exists && ok { + return n < conditionValue + } + return false + } + return nil + case "<=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToString(spanValue); exists && ok { + return n <= conditionValue + } + return false + } + return nil + } + case "int": + // check if conditionValue and spanValue are not equal + conditionValue, ok := tryConvertToInt(r.Value) + if !ok { + return fmt.Errorf("could not convert %v to string", r.Value) + } + switch condition { + case "!=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n != conditionValue + } + return false + } + return nil + case "=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n == conditionValue + } + return false + } + return nil + case ">": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n > conditionValue + } + return false + } + return nil + case ">=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n >= conditionValue + } + return false + } + return nil + case "<": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n < conditionValue + } + return false + } + return nil + case "<=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToInt(spanValue); exists && ok { + return n <= conditionValue + } + return false + } + return nil + } + case "float": + conditionValue, ok := tryConvertToFloat(r.Value) + if !ok { + return fmt.Errorf("could not convert %v to string", r.Value) + } + // check if conditionValue and spanValue are not equal + switch condition { + case "!=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n != conditionValue + } + return false + } + return nil + case "=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n == conditionValue + } + return false + } + return nil + case ">": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n > conditionValue + } + return false + } + return nil + case ">=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n >= conditionValue + } + return false + } + return nil + case "<": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n < conditionValue + } + return false + } + return nil + case "<=": + r.Matches = func(spanValue any, exists bool) bool { + if n, ok := tryConvertToFloat(spanValue); exists && ok { + return n <= conditionValue + } + return false + } + return nil + } + case "bool": + conditionValue := tryConvertToBool(r.Value) + + switch condition { + case "!=": + r.Matches = func(spanValue any, exists bool) bool { + if n := tryConvertToBool(spanValue); exists && n { + return n != conditionValue + } + return false + } + return nil + case "=": + r.Matches = func(spanValue any, exists bool) bool { + if n := tryConvertToBool(spanValue); exists && n { + return n == conditionValue + } + return false + } + return nil + } + case "": + // user did not specify dataype, so do not specify matches function + default: + return fmt.Errorf("%s must be either string, int, float or bool", r.Datatype) + } + return nil +} + +func setMatchStringBasedOperators(r *RulesBasedSamplerCondition, condition string) error { + conditionValue, ok := tryConvertToString(r.Value) + if !ok { + return fmt.Errorf("%s value must be a string, but was '%s'", condition, r.Value) + } + + switch condition { + case "starts-with": + r.Matches = func(spanValue any, exists bool) bool { + s, ok := spanValue.(string) + if ok { + return strings.HasPrefix(s, conditionValue) + } + return false + } + case "contains": + r.Matches = func(spanValue any, exists bool) bool { + s, ok := spanValue.(string) + if ok { + return strings.Contains(s, conditionValue) + } + return false + } + case "does-not-contain": + r.Matches = func(spanValue any, exists bool) bool { + s, ok := spanValue.(string) + if ok { + return !strings.Contains(s, conditionValue) + } + return false + } + } + + return nil +} + type RulesBasedDownstreamSampler struct { DynamicSampler *DynamicSamplerConfig EMADynamicSampler *EMADynamicSamplerConfig diff --git a/sample/rules.go b/sample/rules.go index a255df25b5..7a94661e8f 100644 --- a/sample/rules.go +++ b/sample/rules.go @@ -31,6 +31,15 @@ func (s *RulesBasedSampler) Start() error { // Check if any rule has a downstream sampler and create it for _, rule := range s.Config.Rule { + for _, cond := range rule.Condition { + if err := cond.Init(); err != nil { + s.Logger.Debug().WithFields(map[string]interface{}{ + "rule_name": rule.Name, + "condition": cond.String(), + }).Logf("error creating rule evaluation function: %s", err) + continue + } + } if rule.Sampler != nil { var sampler Sampler if rule.Sampler.DynamicSampler != nil { @@ -136,14 +145,19 @@ func ruleMatchesTrace(t *types.Trace, rule *config.RulesBasedSamplerRule, checkN span: for _, span := range t.GetSpans() { value, exists := extractValueFromSpan(span, condition, checkNestedFields) - - if conditionMatchesValue(condition, value, exists) { + if condition.Matches == nil { + if conditionMatchesValue(condition, value, exists) { + matched++ + break span + } + continue + } else if condition.Matches(value, exists) { matched++ break span } + } } - return matched == len(rule.Condition) } @@ -158,10 +172,18 @@ func ruleMatchesSpanInTrace(trace *types.Trace, rule *config.RulesBasedSamplerRu for _, condition := range rule.Condition { // whether this condition is matched by this span. value, exists := extractValueFromSpan(span, condition, checkNestedFields) + if condition.Matches == nil { + if !conditionMatchesValue(condition, value, exists) { + ruleMatched = false + break // if any condition fails, we can't possibly succeed, so exit inner loop early + } + } - if !conditionMatchesValue(condition, value, exists) { - ruleMatched = false - break // if any condition fails, we can't possibly succeed, so exit inner loop early + if condition.Matches != nil { + if !condition.Matches(value, exists) { + ruleMatched = false + break // if any condition fails, we can't possibly succeed, so exit inner loop early + } } } // If this span was matched by every condition, then the rule as a whole diff --git a/sample/rules_test.go b/sample/rules_test.go index ea1dc166fa..d40531172f 100644 --- a/sample/rules_test.go +++ b/sample/rules_test.go @@ -1060,3 +1060,507 @@ func TestRuleMatchesSpanMatchingSpan(t *testing.T) { }) } } + +func TestRulesDatatypes(t *testing.T) { + data := []TestRulesData{ + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "int64Unchanged", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: int64(1), + Datatype: "int", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": int64(1), + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "floatUnchanged", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: float64(1.01), + Datatype: "float", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": float64(1.01), + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "stringUnchanged", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: "foo", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "foo", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "boolUnchanged", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: "true", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": true, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "bool", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: true, + Datatype: "bool", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": true, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "boolShouldChangeToFalse", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: "blaaahhhh", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": false, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "intToFloat", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: int64(10), + Datatype: "int", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": 10., + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "floatToInt", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: float64(100.01), + Datatype: "float", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": 100, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "invalidConfigComparesStringWithInt", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: "500", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "500", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "stringToInt", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: 500, + Datatype: "int", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "500", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "intToString", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: ">", + Value: "1", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": 2, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "floatToString", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "<", + Value: "10.3", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": 9.3, + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "stringToFloat", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "<=", + Value: 4.13, + Datatype: "float", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "4.13", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "stringToFloatInvalid", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: ">=", + Value: 4.13, + Datatype: "float", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "fourPointOneThree", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "stringNotEqual", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "!=", + Value: "notRightValue", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": "rightValue", + }, + }, + }, + }, + ExpectedKeep: true, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "toStringNotEqual", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "!=", + Value: "667", + Datatype: "string", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": 777, + }, + }, + }, + }, + ExpectedKeep: false, + }, + { + Rules: &config.RulesBasedSamplerConfig{ + Rule: []*config.RulesBasedSamplerRule{ + { + Name: "shouldFail", + SampleRate: 10, + Condition: []*config.RulesBasedSamplerCondition{ + { + Field: "test", + Operator: "=", + Value: int64(1), + Datatype: "int", + }, + }, + }, + }, + }, + Spans: []*types.Span{ + { + Event: types.Event{ + Data: map[string]interface{}{ + "test": int64(1), + }, + }, + }, + }, + ExpectedKeep: false, + }, + } + + for _, d := range data { + sampler := &RulesBasedSampler{ + Config: d.Rules, + Logger: &logger.NullLogger{}, + Metrics: &metrics.NullMetrics{}, + } + + sampler.Start() + + trace := &types.Trace{} + + for _, span := range d.Spans { + trace.AddSpan(span) + } + + _, keep, _ := sampler.GetSampleRate(trace) + + // // we can only test when we don't expect to keep the trace + if !d.ExpectedKeep { + assert.Equal(t, d.ExpectedKeep, keep, d.Rules) + } + } +}