Skip to content

Commit ef2a807

Browse files
bacherflevan-bradleyTylerHelmuth
authored andcommitted
[pkg/ottl] add ValueExpression to support extraction of values from the signal context (open-telemetry#36883)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description This PR adds a new `ParseValueExpression` method to the OTTL parser which allows users of this package to extract data from the context using OTTL <!-- Issue number (e.g. open-telemetry#1234) or full URL to issue, if applicable. --> #### Link to tracking issue Fixes open-telemetry#35621 <!--Describe what testing was performed and which tests were added.--> #### Testing Added unit and e2e tests <!--Describe the documentation added.--> #### Documentation Added godoc comments for the added methods and types <!--Please delete paragraphs that you did not use before submitting.--> --------- Signed-off-by: Florian Bacher <[email protected]> Co-authored-by: Evan Bradley <[email protected]> Co-authored-by: Tyler Helmuth <[email protected]>
1 parent 8a4404b commit ef2a807

File tree

5 files changed

+285
-2
lines changed

5 files changed

+285
-2
lines changed

.chloggen/ottl-value-expression.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: pkg/ottl
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Add value expression parser that enables components using ottl to retrieve values from the output of an expression
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [35621]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext: the expression can be either a literal value, a path value within the context, or the result of a converter and/or a mathematical expression.
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [api]

pkg/ottl/e2e/e2e_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,61 @@ func Test_e2e_ottl_features(t *testing.T) {
11861186
}
11871187
}
11881188

1189+
func Test_e2e_ottl_value_expressions(t *testing.T) {
1190+
tests := []struct {
1191+
name string
1192+
statement string
1193+
want any
1194+
}{
1195+
{
1196+
name: "string literal",
1197+
statement: `"foo"`,
1198+
want: "foo",
1199+
},
1200+
{
1201+
name: "attribute value",
1202+
statement: `resource.attributes["host.name"]`,
1203+
want: "localhost",
1204+
},
1205+
{
1206+
name: "accessing enum",
1207+
statement: `SEVERITY_NUMBER_TRACE`,
1208+
want: int64(1),
1209+
},
1210+
{
1211+
name: "Using converter",
1212+
statement: `TraceID(0x0102030405060708090a0b0c0d0e0f10)`,
1213+
want: pcommon.TraceID{0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10},
1214+
},
1215+
{
1216+
name: "Adding results of two converter operations",
1217+
statement: `Len(attributes) + Len(attributes)`,
1218+
want: int64(24),
1219+
},
1220+
{
1221+
name: "Nested converter operations",
1222+
statement: `Hex(Len(attributes) + Len(attributes))`,
1223+
want: "0000000000000018",
1224+
},
1225+
}
1226+
1227+
for _, tt := range tests {
1228+
t.Run(tt.statement, func(t *testing.T) {
1229+
settings := componenttest.NewNopTelemetrySettings()
1230+
logParser, err := ottllog.NewParser(ottlfuncs.StandardFuncs[ottllog.TransformContext](), settings)
1231+
assert.NoError(t, err)
1232+
valueExpr, err := logParser.ParseValueExpression(tt.statement)
1233+
assert.NoError(t, err)
1234+
1235+
tCtx := constructLogTransformContext()
1236+
val, err := valueExpr.Eval(context.Background(), tCtx)
1237+
assert.NoError(t, err)
1238+
1239+
assert.Equal(t, tt.want, val)
1240+
})
1241+
}
1242+
}
1243+
11891244
func Test_ProcessTraces_TraceContext(t *testing.T) {
11901245
tests := []struct {
11911246
statement string

pkg/ottl/grammar.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ type value struct {
245245
List *list `parser:"| @@)"`
246246
}
247247

248+
func (v *value) checkForCustomError() error {
249+
validator := &grammarCustomErrorsVisitor{}
250+
v.accept(validator)
251+
return validator.join()
252+
}
253+
248254
func (v *value) accept(vis grammarVisitor) {
249255
vis.visitValue(v)
250256
if v.Literal != nil {

pkg/ottl/parser.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,9 @@ func (p *Parser[K]) prependContextToStatementPaths(context string, statement str
232232
}
233233

234234
var (
235-
parser = newParser[parsedStatement]()
236-
conditionParser = newParser[booleanExpression]()
235+
parser = newParser[parsedStatement]()
236+
conditionParser = newParser[booleanExpression]()
237+
valueExpressionParser = newParser[value]()
237238
)
238239

239240
func parseStatement(raw string) (*parsedStatement, error) {
@@ -262,6 +263,19 @@ func parseCondition(raw string) (*booleanExpression, error) {
262263
return parsed, nil
263264
}
264265

266+
func parseValueExpression(raw string) (*value, error) {
267+
parsed, err := valueExpressionParser.ParseString("", raw)
268+
if err != nil {
269+
return nil, fmt.Errorf("expression has invalid syntax: %w", err)
270+
}
271+
err = parsed.checkForCustomError()
272+
if err != nil {
273+
return nil, err
274+
}
275+
276+
return parsed, nil
277+
}
278+
265279
func insertContextIntoStatementOffsets(context string, statement string, offsets []int) (string, error) {
266280
if len(offsets) == 0 {
267281
return statement, nil
@@ -439,3 +453,33 @@ func (c *ConditionSequence[K]) Eval(ctx context.Context, tCtx K) (bool, error) {
439453
// It is not possible to get here if any condition during an AND explicitly failed.
440454
return c.logicOp == And && atLeastOneMatch, nil
441455
}
456+
457+
// ValueExpression represents an expression that resolves to a value. The returned value can be of any type,
458+
// and the expression can be either a literal value, a path value within the context, or the result of a converter and/or
459+
// a mathematical expression.
460+
// This allows other components using this library to extract data from the context of the incoming signal using OTTL.
461+
type ValueExpression[K any] struct {
462+
getter Getter[K]
463+
}
464+
465+
// Eval evaluates the given expression and returns the value the expression resolves to.
466+
func (e *ValueExpression[K]) Eval(ctx context.Context, tCtx K) (any, error) {
467+
return e.getter.Get(ctx, tCtx)
468+
}
469+
470+
// ParseValueExpression parses an expression string into a ValueExpression. The ValueExpression's Eval
471+
// method can then be used to extract the value from the context of the incoming signal.
472+
func (p *Parser[K]) ParseValueExpression(raw string) (*ValueExpression[K], error) {
473+
parsed, err := parseValueExpression(raw)
474+
if err != nil {
475+
return nil, err
476+
}
477+
getter, err := p.newGetter(*parsed)
478+
if err != nil {
479+
return nil, err
480+
}
481+
482+
return &ValueExpression[K]{
483+
getter: getter,
484+
}, nil
485+
}

pkg/ottl/parser_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/stretchr/testify/assert"
1717
"github.com/stretchr/testify/require"
1818
"go.opentelemetry.io/collector/component/componenttest"
19+
"go.opentelemetry.io/collector/pdata/pcommon"
1920

2021
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl/ottltest"
2122
)
@@ -2130,6 +2131,118 @@ func testParseEnum(val *EnumSymbol) (*Enum, error) {
21302131
return nil, fmt.Errorf("enum symbol not provided")
21312132
}
21322133

2134+
func Test_parseValueExpression_full(t *testing.T) {
2135+
time1 := time.Now()
2136+
time2 := time1.Add(5 * time.Second)
2137+
tests := []struct {
2138+
name string
2139+
valueExpression string
2140+
tCtx any
2141+
expected func() any
2142+
}{
2143+
{
2144+
name: "string value",
2145+
valueExpression: `"fido"`,
2146+
expected: func() any {
2147+
return "fido"
2148+
},
2149+
},
2150+
{
2151+
name: "resolve context value",
2152+
valueExpression: `attributes`,
2153+
expected: func() any {
2154+
return map[string]any{
2155+
"attributes": map[string]any{
2156+
"foo": "bar",
2157+
},
2158+
}
2159+
},
2160+
tCtx: map[string]any{
2161+
"attributes": map[string]any{
2162+
"foo": "bar",
2163+
},
2164+
},
2165+
},
2166+
{
2167+
name: "resolve math expression",
2168+
valueExpression: `time2 - time1`,
2169+
expected: func() any {
2170+
return 5 * time.Second
2171+
},
2172+
tCtx: map[string]time.Time{
2173+
"time1": time1,
2174+
"time2": time2,
2175+
},
2176+
},
2177+
{
2178+
name: "nil",
2179+
valueExpression: `nil`,
2180+
expected: func() any {
2181+
return nil
2182+
},
2183+
},
2184+
{
2185+
name: "string",
2186+
valueExpression: `"string"`,
2187+
expected: func() any {
2188+
return "string"
2189+
},
2190+
},
2191+
{
2192+
name: "hex values",
2193+
valueExpression: `[0x0000000000000000, 0x0000000000000000]`,
2194+
expected: func() any {
2195+
return []any{
2196+
[]uint8{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
2197+
[]uint8{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
2198+
}
2199+
},
2200+
},
2201+
{
2202+
name: "boolean",
2203+
valueExpression: `true`,
2204+
expected: func() any {
2205+
return true
2206+
},
2207+
},
2208+
{
2209+
name: "map",
2210+
valueExpression: `{"map": 1}`,
2211+
expected: func() any {
2212+
m := pcommon.NewMap()
2213+
_ = m.FromRaw(map[string]any{
2214+
"map": 1,
2215+
})
2216+
return m
2217+
},
2218+
},
2219+
{
2220+
name: "string list",
2221+
valueExpression: `["list", "of", "strings"]`,
2222+
expected: func() any {
2223+
return []any{"list", "of", "strings"}
2224+
},
2225+
},
2226+
}
2227+
2228+
for _, tt := range tests {
2229+
t.Run(tt.valueExpression, func(t *testing.T) {
2230+
p, _ := NewParser(
2231+
CreateFactoryMap[any](),
2232+
testParsePath[any],
2233+
componenttest.NewNopTelemetrySettings(),
2234+
WithEnumParser[any](testParseEnum),
2235+
)
2236+
parsed, err := p.ParseValueExpression(tt.valueExpression)
2237+
assert.NoError(t, err)
2238+
2239+
v, err := parsed.Eval(context.Background(), tt.tCtx)
2240+
require.NoError(t, err)
2241+
assert.Equal(t, tt.expected(), v)
2242+
})
2243+
}
2244+
}
2245+
21332246
func Test_ParseStatements_Error(t *testing.T) {
21342247
statements := []string{
21352248
`set(`,
@@ -2343,6 +2456,44 @@ func Test_parseCondition(t *testing.T) {
23432456
}
23442457
}
23452458

2459+
// This test doesn't validate parser results, simply checks whether the parse succeeds or not.
2460+
// It's a fast way to check a large range of possible syntaxes.
2461+
func Test_parseValueExpression(t *testing.T) {
2462+
converterNameErrorPrefix := "converter names must start with an uppercase letter"
2463+
editorWithIndexErrorPrefix := "only paths and converters may be indexed"
2464+
2465+
tests := []struct {
2466+
valueExpression string
2467+
wantErr bool
2468+
wantErrContaining string
2469+
}{
2470+
{valueExpression: `time_end - time_end`},
2471+
{valueExpression: `time_end - time_end - attributes["foo"]`},
2472+
{valueExpression: `Test("foo")`},
2473+
{valueExpression: `Test(Test("foo")) - attributes["bar"]`},
2474+
{valueExpression: `Test(Test("foo")) - attributes["bar"]"`, wantErr: true},
2475+
{valueExpression: `test("foo")`, wantErr: true, wantErrContaining: converterNameErrorPrefix},
2476+
{valueExpression: `test(animal)["kind"]`, wantErrContaining: editorWithIndexErrorPrefix},
2477+
{valueExpression: `Test("a"")foo"`, wantErr: true},
2478+
{valueExpression: `Test("a"") == 1"`, wantErr: true},
2479+
}
2480+
pat := regexp.MustCompile("[^a-zA-Z0-9]+")
2481+
for _, tt := range tests {
2482+
name := pat.ReplaceAllString(tt.valueExpression, "_")
2483+
t.Run(name, func(t *testing.T) {
2484+
ast, err := parseValueExpression(tt.valueExpression)
2485+
if (err != nil) != (tt.wantErr || tt.wantErrContaining != "") {
2486+
t.Errorf("parseCondition(%s) error = %v, wantErr %v", tt.valueExpression, err, tt.wantErr)
2487+
t.Errorf("AST: %+v", ast)
2488+
return
2489+
}
2490+
if tt.wantErrContaining != "" {
2491+
require.ErrorContains(t, err, tt.wantErrContaining)
2492+
}
2493+
})
2494+
}
2495+
}
2496+
23462497
func Test_Statement_Execute(t *testing.T) {
23472498
tests := []struct {
23482499
name string

0 commit comments

Comments
 (0)