From 44665658f57ac4f3b6cc30e9e5fb80533202873c Mon Sep 17 00:00:00 2001 From: Tyler Helmuth <12352919+TylerHelmuth@users.noreply.github.com> Date: Fri, 17 Nov 2023 16:05:42 -0700 Subject: [PATCH] [pkg/ottl] Add ability to parse conditions (#29315) **Description:** This PR adds a new public API to support parsing and using conditions. This implementation opted to add ParseConditions directly to the existing Parser instead of creating a second Parser struct. Since any Parser needs the Context, Functions, PathExpressionParser, EnumParser, and telemetry settings the 2 structs would be incredibly similar. See these structs implemented in components here: https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/29294 **Link to tracking Issue:** Works towards https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/13545 **Testing:** Added tests **Documentation:** Added godoc strings --- .../ottl-condition-parser-add-interfaces.yaml | 27 ++ pkg/ottl/parser.go | 83 +++++- pkg/ottl/parser_test.go | 268 +++++++++++++++++- 3 files changed, 370 insertions(+), 8 deletions(-) create mode 100755 .chloggen/ottl-condition-parser-add-interfaces.yaml diff --git a/.chloggen/ottl-condition-parser-add-interfaces.yaml b/.chloggen/ottl-condition-parser-add-interfaces.yaml new file mode 100755 index 000000000000..d693a989699b --- /dev/null +++ b/.chloggen/ottl-condition-parser-add-interfaces.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: pkg/ottl + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add ability to independently parse OTTL conditions. + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [29315] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [api] diff --git a/pkg/ottl/parser.go b/pkg/ottl/parser.go index 35ee87025674..f1dbc18f3f4c 100644 --- a/pkg/ottl/parser.go +++ b/pkg/ottl/parser.go @@ -32,13 +32,6 @@ func (e *ErrorMode) UnmarshalText(text []byte) error { } } -type Parser[K any] struct { - functions map[string]Factory[K] - pathParser PathExpressionParser[K] - enumParser EnumParser - telemetrySettings component.TelemetrySettings -} - // Statement holds a top level Statement for processing telemetry data. A Statement is a combination of a function // invocation and the boolean expression to match telemetry for invoking the function. type Statement[K any] struct { @@ -66,6 +59,26 @@ func (s *Statement[K]) Execute(ctx context.Context, tCtx K) (any, bool, error) { return result, condition, nil } +// Condition holds a top level Condition. A Condition is a boolean expression to match telemetry. +type Condition[K any] struct { + condition BoolExpr[K] + origText string +} + +// Eval returns true if the condition was met for the given TransformContext and false otherwise. +func (c *Condition[K]) Eval(ctx context.Context, tCtx K) (bool, error) { + return c.condition.Eval(ctx, tCtx) +} + +// Parser provides the means to parse OTTL Statements and Conditions given a specific set of functions, +// a PathExpressionParser, and an EnumParser. +type Parser[K any] struct { + functions map[string]Factory[K] + pathParser PathExpressionParser[K] + enumParser EnumParser + telemetrySettings component.TelemetrySettings +} + func NewParser[K any]( functions map[string]Factory[K], pathParser PathExpressionParser[K], @@ -143,7 +156,49 @@ func (p *Parser[K]) ParseStatement(statement string) (*Statement[K], error) { }, nil } +// ParseConditions parses string conditions into a Condition slice ready for execution. +// Returns a slice of Condition and a nil error on successful parsing. +// If parsing fails, returns nil and an error containing each error per failed condition. +func (p *Parser[K]) ParseConditions(conditions []string) ([]*Condition[K], error) { + parsedConditions := make([]*Condition[K], 0, len(conditions)) + var parseErrs []error + + for _, condition := range conditions { + ps, err := p.ParseCondition(condition) + if err != nil { + parseErrs = append(parseErrs, fmt.Errorf("unable to parse OTTL condition %q: %w", condition, err)) + continue + } + parsedConditions = append(parsedConditions, ps) + } + + if len(parseErrs) > 0 { + return nil, errors.Join(parseErrs...) + } + + return parsedConditions, nil +} + +// ParseCondition parses a single string condition into a Condition objects ready for execution. +// Returns an Condition and a nil error on successful parsing. +// If parsing fails, returns nil and an error. +func (p *Parser[K]) ParseCondition(condition string) (*Condition[K], error) { + parsed, err := parseCondition(condition) + if err != nil { + return nil, err + } + expression, err := p.newBoolExpr(parsed) + if err != nil { + return nil, err + } + return &Condition[K]{ + condition: expression, + origText: condition, + }, nil +} + var parser = newParser[parsedStatement]() +var conditionParser = newParser[booleanExpression]() func parseStatement(raw string) (*parsedStatement, error) { parsed, err := parser.ParseString("", raw) @@ -159,6 +214,20 @@ func parseStatement(raw string) (*parsedStatement, error) { return parsed, nil } +func parseCondition(raw string) (*booleanExpression, error) { + parsed, err := conditionParser.ParseString("", raw) + + if err != nil { + return nil, fmt.Errorf("condition has invalid syntax: %w", err) + } + err = parsed.checkForCustomError() + if err != nil { + return nil, err + } + + return parsed, nil +} + // newParser returns a parser that can be used to read a string into a parsedStatement. An error will be returned if the string // is not formatted for the DSL. func newParser[G any]() *participle.Parser[G] { diff --git a/pkg/ottl/parser_test.go b/pkg/ottl/parser_test.go index 44874bf07405..b5365ca6a2f4 100644 --- a/pkg/ottl/parser_test.go +++ b/pkg/ottl/parser_test.go @@ -1088,6 +1088,152 @@ func Test_parse(t *testing.T) { } } +func Test_parseCondition_full(t *testing.T) { + tests := []struct { + name string + condition string + expected *booleanExpression + }{ + { + name: "where == clause", + condition: `name == "fido"`, + expected: &booleanExpression{ + Left: &term{ + Left: &booleanValue{ + Comparison: &comparison{ + Left: value{ + Literal: &mathExprLiteral{ + Path: &Path{ + Fields: []Field{ + { + Name: "name", + }, + }, + }, + }, + }, + Op: EQ, + Right: value{ + String: ottltest.Strp("fido"), + }, + }, + }, + }, + }, + }, + { + name: "where != clause", + condition: `name != "fido"`, + expected: &booleanExpression{ + Left: &term{ + Left: &booleanValue{ + Comparison: &comparison{ + Left: value{ + Literal: &mathExprLiteral{ + Path: &Path{ + Fields: []Field{ + { + Name: "name", + }, + }, + }, + }, + }, + Op: NE, + Right: value{ + String: ottltest.Strp("fido"), + }, + }, + }, + }, + }, + }, + { + name: "Converter math mathExpression", + condition: `1 + 1 * 2 == three / One()`, + expected: &booleanExpression{ + Left: &term{ + Left: &booleanValue{ + Comparison: &comparison{ + Left: value{ + MathExpression: &mathExpression{ + Left: &addSubTerm{ + Left: &mathValue{ + Literal: &mathExprLiteral{ + Int: ottltest.Intp(1), + }, + }, + }, + Right: []*opAddSubTerm{ + { + Operator: ADD, + Term: &addSubTerm{ + Left: &mathValue{ + Literal: &mathExprLiteral{ + Int: ottltest.Intp(1), + }, + }, + Right: []*opMultDivValue{ + { + Operator: MULT, + Value: &mathValue{ + Literal: &mathExprLiteral{ + Int: ottltest.Intp(2), + }, + }, + }, + }, + }, + }, + }, + }, + }, + Op: EQ, + Right: value{ + MathExpression: &mathExpression{ + Left: &addSubTerm{ + Left: &mathValue{ + Literal: &mathExprLiteral{ + Path: &Path{ + Fields: []Field{ + { + Name: "three", + }, + }, + }, + }, + }, + Right: []*opMultDivValue{ + { + Operator: DIV, + Value: &mathValue{ + Literal: &mathExprLiteral{ + Converter: &converter{ + Function: "One", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.condition, func(t *testing.T) { + parsed, err := parseCondition(tt.condition) + assert.NoError(t, err) + assert.EqualValues(t, tt.expected, parsed) + }) + } +} + func testParsePath(val *Path) (GetSetter[any], error) { if val != nil && len(val.Fields) > 0 && (val.Fields[0].Name == "name" || val.Fields[0].Name == "attributes") { return &StandardGetSetter[any]{ @@ -1655,6 +1801,37 @@ func Test_ParseStatements_Error(t *testing.T) { } } +func Test_ParseConditions_Error(t *testing.T) { + conditions := []string{ + `True(`, + `"foo == "foo"`, + `set()`, + } + + p, _ := NewParser( + CreateFactoryMap[any](), + testParsePath, + componenttest.NewNopTelemetrySettings(), + WithEnumParser[any](testParseEnum), + ) + + _, err := p.ParseConditions(conditions) + + assert.Error(t, err) + + var e interface{ Unwrap() []error } + if errors.As(err, &e) { + uw := e.Unwrap() + assert.Len(t, uw, len(conditions), "ParseConditions didn't return an error per condition") + + for i, conditionErr := range uw { + assert.ErrorContains(t, conditionErr, fmt.Sprintf("unable to parse OTTL condition %q", conditions[i])) + } + } else { + assert.Fail(t, "ParseConditions didn't return an error per condition") + } +} + // This test doesn't validate parser results, simply checks whether the parse succeeds or not. // It's a fast way to check a large range of possible syntaxes. func Test_parseStatement(t *testing.T) { @@ -1727,7 +1904,66 @@ func Test_parseStatement(t *testing.T) { } } -func Test_Execute(t *testing.T) { +// This test doesn't validate parser results, simply checks whether the parse succeeds or not. +// It's a fast way to check a large range of possible syntaxes. +func Test_parseCondition(t *testing.T) { + tests := []struct { + condition string + wantErr bool + }{ + {`set(`, true}, + {`set("foo)`, true}, + {`set(name.)`, true}, + {`("foo")`, true}, + {`name =||= "fido"`, true}, + {`name = "fido"`, true}, + {`name or "fido"`, true}, + {`name and "fido"`, true}, + {`name and`, true}, + {`name or`, true}, + {`(`, true}, + {`)`, true}, + {`(name == "fido"))`, true}, + {`((name == "fido")`, true}, + {`set()`, true}, + {`Int() == 1`, false}, + {`1 == Int()`, false}, + {`true and 1 == Int() `, false}, + {`false or 1 == Int() `, false}, + {`service == "pinger" or foo.attributes["endpoint"] == "/x/alive"`, false}, + {`service == "pinger" or foo.attributes["verb"] == "GET" and foo.attributes["endpoint"] == "/x/alive"`, false}, + {`animal > "cat"`, false}, + {`animal >= "cat"`, false}, + {`animal <= "cat"`, false}, + {`animal < "cat"`, false}, + {`animal =< "dog"`, true}, + {`animal => "dog"`, true}, + {`animal <> "dog"`, true}, + {`animal = "dog"`, true}, + {`animal`, true}, + {`animal ==`, true}, + {`==`, true}, + {`== animal`, true}, + {`attributes["path"] == "/healthcheck"`, false}, + {`One() == 1`, false}, + {`test(fail())`, true}, + {`Test()`, false}, + } + pat := regexp.MustCompile("[^a-zA-Z0-9]+") + for _, tt := range tests { + name := pat.ReplaceAllString(tt.condition, "_") + t.Run(name, func(t *testing.T) { + ast, err := parseCondition(tt.condition) + if (err != nil) != tt.wantErr { + t.Errorf("parseCondition(%s) error = %v, wantErr %v", tt.condition, err, tt.wantErr) + t.Errorf("AST: %+v", ast) + return + } + }) + } +} + +func Test_Statement_Execute(t *testing.T) { tests := []struct { name string condition boolExpressionEvaluator[any] @@ -1778,6 +2014,36 @@ func Test_Execute(t *testing.T) { } } +func Test_Condition_Eval(t *testing.T) { + tests := []struct { + name string + condition boolExpressionEvaluator[any] + expectedResult bool + }{ + { + name: "Condition matched", + condition: alwaysTrue[any], + expectedResult: true, + }, + { + name: "Condition not matched", + condition: alwaysFalse[any], + expectedResult: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + condition := Condition[any]{ + condition: BoolExpr[any]{tt.condition}, + } + + result, err := condition.Eval(context.Background(), nil) + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + func Test_Statements_Execute_Error(t *testing.T) { tests := []struct { name string