Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pkg/ottl] Add the ottl.ParserCollection utility #36174

Merged
Merged
30 changes: 30 additions & 0 deletions .chloggen/ottl-add-parser-collection-utility.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 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 the `ottl.ParserCollection` utility to help handling multiple OTTL contexts parsers"
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [29017]

# (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: |
The `ottl.ParserCollection` groups context's `ottl.Parser` choosing the suitable one
when parsing statements. It does support context inference from the given statements,
and allows prepending the context name to the statement's paths.
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved

# 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]
328 changes: 328 additions & 0 deletions pkg/ottl/parser_collection.go
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package ottl // import "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl"

import (
"fmt"
"reflect"

"go.opentelemetry.io/collector/component"
"go.uber.org/zap"
)

// Safeguard to statically ensure the Parser.ParseStatements method can be reflectively
// invoked by the ottlParserWrapper.parseStatements
var _ interface {
ParseStatements(statements []string) ([]*Statement[any], error)
} = (*Parser[any])(nil)
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved

// Safeguard to statically ensure any ParsedStatementConverter method can be reflectively
// invoked by the statementsConverterWrapper.call
var _ ParsedStatementConverter[any, any] = func(
_ *ParserCollection[any],
_ *Parser[any],
_ string,
_ StatementsGetter,
_ []*Statement[any]) (any, error) {
return nil, nil
}

// StatementsGetter represents a set of statements to be parsed.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
type StatementsGetter interface {
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
// GetStatements retrieves the OTTL statements to be parsed
GetStatements() []string
}

type defaultStatementsGetter []string

func (d defaultStatementsGetter) GetStatements() []string {
return d
}

// NewStatementsGetter creates a new StatementsGetter.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func NewStatementsGetter(statements []string) StatementsGetter {
return defaultStatementsGetter(statements)
}

// ottlParserWrapper wraps an ottl.Parser using reflection, so it can invoke exported
// methods without knowing its generic type (transform context).
type ottlParserWrapper struct {
parser reflect.Value
prependContextToStatementPaths func(context string, statement string) (string, error)
}

func newParserWrapper[K any](parser *Parser[K]) *ottlParserWrapper {
return &ottlParserWrapper{
parser: reflect.ValueOf(parser),
prependContextToStatementPaths: parser.prependContextToStatementPaths,
}
}

func (g *ottlParserWrapper) parseStatements(statements []string) (reflect.Value, error) {
method := g.parser.MethodByName("ParseStatements")
parseStatementsRes := method.Call([]reflect.Value{reflect.ValueOf(statements)})
err := parseStatementsRes[1]
if !err.IsNil() {
return reflect.Value{}, err.Interface().(error)
}
return parseStatementsRes[0], nil
}

func (g *ottlParserWrapper) prependContextToStatementsPaths(context string, statements []string) ([]string, error) {
result := make([]string, 0, len(statements))
for _, s := range statements {
prependedStatement, err := g.prependContextToStatementPaths(context, s)
if err != nil {
return nil, err
}
result = append(result, prependedStatement)
}
return result, nil
}

// statementsConverterWrapper is a reflection-based wrapper to the ParsedStatementConverter function,
// which does not require knowing all generic parameters to be called.
type statementsConverterWrapper reflect.Value

func newStatementsConverterWrapper[K any, R any](converter ParsedStatementConverter[K, R]) statementsConverterWrapper {
return statementsConverterWrapper(reflect.ValueOf(converter))
}

func (s statementsConverterWrapper) call(
parserCollection reflect.Value,
ottlParser *ottlParserWrapper,
context string,
statements StatementsGetter,
parsedStatements reflect.Value,
) (reflect.Value, error) {
result := reflect.Value(s).Call([]reflect.Value{
parserCollection,
ottlParser.parser,
reflect.ValueOf(context),
reflect.ValueOf(statements),
parsedStatements,
})

resultValue := result[0]
resultError := result[1]
if !resultError.IsNil() {
return reflect.Value{}, resultError.Interface().(error)
}

return resultValue, nil
}

// parserCollectionParser holds an ottlParserWrapper and its respectively
// statementsConverter function.
type parserCollectionParser struct {
ottlParser *ottlParserWrapper
statementsConverter statementsConverterWrapper
}

// ParserCollection is a configurable set of ottl.Parser that can handle multiple OTTL contexts
// parsings, inferring the context, choosing the right parser for the given statements, and
// transforming the parsed ottl.Statement[K] slice into a common result of type R.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
type ParserCollection[R any] struct {
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
contextParsers map[string]*parserCollectionParser
contextInferrer contextInferrer
modifiedStatementLogging bool
Settings component.TelemetrySettings
ErrorMode ErrorMode
}

// ParserCollectionOption is a configurable ParserCollection option.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
type ParserCollectionOption[R any] func(*ParserCollection[R]) error

// NewParserCollection creates a new ParserCollection.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func NewParserCollection[R any](
settings component.TelemetrySettings,
options ...ParserCollectionOption[R]) (*ParserCollection[R], error) {
pc := &ParserCollection[R]{
Settings: settings,
contextParsers: map[string]*parserCollectionParser{},
contextInferrer: defaultPriorityContextInferrer(),
}

for _, op := range options {
err := op(pc)
if err != nil {
return nil, err
}
}

return pc, nil
}

// ParsedStatementConverter is a function that converts the parsed ottl.Statement[K] into
// a common representation to all parser collection contexts WithParserCollectionContext.
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
// Given each parser has its own transform context type, they must agree on a common type [R]
// so is can be returned by the ParserCollection.ParseStatements and ParserCollection.ParseStatementsWithContext
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
// functions.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
type ParsedStatementConverter[K any, R any] func(
collection *ParserCollection[R],
parser *Parser[K],
context string,
statements StatementsGetter,
parsedStatements []*Statement[K],
) (R, error)

func newNopParsedStatementConverter[K any]() ParsedStatementConverter[K, any] {
return func(
_ *ParserCollection[any],
_ *Parser[K],
_ string,
_ StatementsGetter,
parsedStatements []*Statement[K]) (any, error) {
return parsedStatements, nil
}
}

// WithParserCollectionContext configures an ottl.Parser for the given context.
// The provided ottl.Parser must be configured to support the provided context using
// the ottl.WithPathContextNames option.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func WithParserCollectionContext[K any, R any](
context string,
parser *Parser[K],
converter ParsedStatementConverter[K, R],
) ParserCollectionOption[R] {
return func(mp *ParserCollection[R]) error {
if _, ok := parser.pathContextNames[context]; !ok {
return fmt.Errorf(`context "%s" must be a valid "%T" path context name`, context, parser)
}
mp.contextParsers[context] = &parserCollectionParser{
ottlParser: newParserWrapper[K](parser),
statementsConverter: newStatementsConverterWrapper(converter),
}
return nil
}
}

// WithParserCollectionErrorMode has no effect on the ParserCollection, but might be used
// by the ParsedStatementConverter functions to handle/create StatementSequence.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func WithParserCollectionErrorMode[R any](errorMode ErrorMode) ParserCollectionOption[R] {
return func(tp *ParserCollection[R]) error {
tp.ErrorMode = errorMode
return nil
}
}

// EnableParserCollectionModifiedStatementLogging controls the statements modification logs.
// When enabled, it logs any statements modifications performed by the parsing operations,
// instructing users to rewrite the statements accordingly.
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func EnableParserCollectionModifiedStatementLogging[R any](enabled bool) ParserCollectionOption[R] {
return func(tp *ParserCollection[R]) error {
tp.modifiedStatementLogging = enabled
return nil
}
}

// ParseStatements parses the given statements into [R] using the configured context's ottl.Parser
// and subsequently calling the ParsedStatementConverter function.
// The statement's context is automatically inferred from the [Path.Context] values, choosing the
// highest priority context found.
// If no contexts are present in the statements, or if the inferred value is not supported by
// the [ParserCollection], it returns an error.
// If parsing the statements fails, it returns the underline [ottl.Parser.ParseStatements] error.
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func (pc *ParserCollection[R]) ParseStatements(statements StatementsGetter) (R, error) {
statementsValues := statements.GetStatements()
inferredContext, err := pc.contextInferrer.infer(statementsValues)
if err != nil {
return *new(R), err
}

if inferredContext == "" {
return *new(R), fmt.Errorf("unable to infer context from statements [%v], path's first segment must be a valid context name", statementsValues)
}

return pc.ParseStatementsWithContext(inferredContext, statements, false)
}

// ParseStatementsWithContext parses the given statements into [R] using the configured
// context's ottl.Parser and subsequently calling the ParsedStatementConverter function.
// Differently from ParseStatements, it uses the provided context and does not infer it
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
// automatically. The context valuer must be supported by the [ParserCollection],
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
// otherwise an error is returned.
// If the statement's Path does not provide their Path.Context value, the prependPathsContext
// argument should be set to true, so it rewrites the statements prepending the missing paths
// contexts.
// If parsing the statements fails, it returns the underline [ottl.Parser.ParseStatements] error.
TylerHelmuth marked this conversation as resolved.
Show resolved Hide resolved
//
// Experimental: *NOTE* this API is subject to change or removal in the future.
func (pc *ParserCollection[R]) ParseStatementsWithContext(context string, statements StatementsGetter, prependPathsContext bool) (R, error) {
contextParser, ok := pc.contextParsers[context]
if !ok {
return *new(R), fmt.Errorf(`unknown context "%s" for stataments: %v`, context, statements.GetStatements())
}

var err error
var parsingStatements []string
if prependPathsContext {
originalStatements := statements.GetStatements()
parsingStatements, err = contextParser.ottlParser.prependContextToStatementsPaths(context, originalStatements)
if err != nil {
return *new(R), err
}
if pc.modifiedStatementLogging {
pc.logModifiedStatements(originalStatements, parsingStatements)
}
} else {
parsingStatements = statements.GetStatements()
}

parsedStatements, err := contextParser.ottlParser.parseStatements(parsingStatements)
if err != nil {
return *new(R), err
}

convertedStatements, err := contextParser.statementsConverter.call(
reflect.ValueOf(pc),
contextParser.ottlParser,
context,
statements,
parsedStatements,
)

if err != nil {
return *new(R), err
}

return convertedStatements.Interface().(R), nil
}

func (pc *ParserCollection[R]) logModifiedStatements(originalStatements, modifiedStatements []string) {
var fields []zap.Field
for i, original := range originalStatements {
if modifiedStatements[i] != original {
statementKey := fmt.Sprintf("[%v]", i)
fields = append(fields, zap.Dict(
statementKey,
zap.String("original", original),
zap.String("modified", modifiedStatements[i])),
)
}
}
if len(fields) > 0 {
pc.Settings.Logger.Info("one or more statements were modified to include their paths context, please rewrite them accordingly", zap.Dict("statements", fields...))
}
}
Loading
Loading