-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Replace function interfaces with a factory #14712
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
Comments
@bogdandrutu since ottl is now specific to the collector, I like the idea of each function getting some standard arguments. Instead of separating type ottlSettings struct {
ctx context.Context
set component.TelemetrySettings
}
type Arguments interface{}
type Factory[K any] interface {
CreateFunction(ottlSet ottlSettings, args Arguments) (ExprFunc[K], error)
} The advantage to this would be that if we want to add some other default params we can add it to the struct without needing to change the definition of
NVM, I understand now why you added |
I played around with this some today and I think the biggest concern I have is the amount of "boilerplate" code that each function would have to define. If we are gonna introduce the need for these 3 items in each function type DeleteKeyArguments {
Target ottl.Getter[K]
Key string
}
type DeleteKeyFactory[K any] struct {}
func (f *DeleteKeyFactory[K]) CreateEmptyArguments() Arguments {
return &DeleteKeyArguments{}
} Then I want to make sure its for a good reason. @bogdandrutu do you have some functionality in mind that we'd want to extend in the future where we'd need the interface? As for ensuring each function has the default params, I think we could handle that with the existing solution be updating newFunctionCall to always add the default params at the beginning. Any function that doesn't implement this wouldn't be callable by the reflection. Downside is it is not enforced by the compiler. |
@bogdandrutu what did you have in mind for how we'd build out the Arguments interface for each function with reflection? The existing solution relies on the function definition to define the order of the parameter and performs a "1 to 1" match on the incoming order of the parsed values. How can we map the incoming params to the correct struct field in Arguments? Is the idea that the order of the fields in the struct definition matters? |
@bogdandrutu Could you go into more detail about this? What would be left unfixed, and what additional interfaces might be useful to implement? I'm having trouble coming up with a use case where we wouldn't prefer to instead make the factory function return types more extensible, maybe along the lines of what is described in #14713. The type OTTLComponentData[K any] struct {
ctx context.Context
settings component.TelemetrySettings
}
func (ocd *OTTLComponentData[K]) DeleteKey(target ottl.Getter[K], key string) (ottl.ExprFunc[K], error) {
return func(ctx K) any {
val := target.Get(ctx)
if val == nil {
return nil
}
if attrs, ok := val.(pcommon.Map); ok {
ocd.settings.Logger.Debug("Removing key")
attrs.Remove(key)
}
return nil
}, nil
}
// SamplingComponentData provides a sampler for a hypothetical sampling processor using the OTTL.
type SamplingComponentData[K any] struct {
OTTLComponentData
// sampler samples telemetry data as an example of stateful processing using the OTTL.
sampler Sampler
}
func (scd *SamplingComponentData[K]) Sample(target ottl.Getter[K]) (ExprFunc, error) {
return func(ctx K) any {
return scd.sampler.ShouldSample(target.Get(ctx))
}, nil
}
func Functions[K any](ocd *OTTLComponentData[K], scd *SamplingComponentData[K]) map[string]any {
return map[string]any{
"delete_key": ocd.DeleteKey[K],
"sample": scd.Sample[K],
}
} This would have a few benefits over how the OTTL currently works:
The primary downside I see is that components would need to do a little more setup to use the OTTL since they would need to handle instantiating the structs. |
@evan-bradley I like how your solution handles the providing access to the shared context and keeps the function signature simple. Requiring the component to instantiate the structs is a downside, but maybe we could provide a helper method for ottlfunc functions. We also pass the same values to the Parser, so it would be nice to not have to pass that information twice. I don't think it solves the issue that @bogdandrutu has raised, though, which is removing the need for |
This issue has been inactive for 60 days. It will be closed in 60 days if there is no activity. To ping code owners by adding a component label, see Adding Labels via Comments, or if you are unsure of which component this issue relates to, please ping Pinging code owners:
See Adding Labels via Comments if you do not have permissions to add labels yourself. |
@bogdandrutu I reviewed this issue again and here are my thoughts: I like the idea of extending function creation to be more structured via a factory so that the package can scale better. I like the idea of the functions being members of structs so that they can get access to the struct's fields, such as I don't like the idea of using an Arguments struct and
In both situations I am worried about function creators getting tripped up by requirements that aren't enforced by Golang. To try to reconcile my dislikes I came up with an approach that lets us continue to use a function during reflection but also use a factory: type FunctionFactory[K any] interface {
GetFunctionSignature() reflect.Type
CreateFunction(args []reflect.Value) (ExprFunc[K], error)
}
func (p *Parser[K]) newFunctionCall(inv invocation) (Expr[K], error) {
f, ok := p.functions[inv.Function]
if !ok {
return Expr[K]{}, fmt.Errorf("undefined function %v", inv.Function)
}
args, err := p.buildArgs(inv, f.GetFunctionSignature())
if err != nil {
return Expr[K]{}, fmt.Errorf("error while parsing arguments for call to '%v': %w", inv.Function, err)
}
exprFunc, err := f.CreateFunction(args)
if err != nil {
return Expr[K]{}, err
}
return Expr[K]{exprFunc: exprFunc}, err
}
type functionWithIntFactory[K any] struct{}
func (f functionWithIntFactory[K]) GetFunctionSignature() reflect.Type {
return reflect.TypeOf(f.functionWithInt)
}
func (f functionWithIntFactory[K]) CreateFunction(args []reflect.Value) (ExprFunc[K], error) {
return convert[K](reflect.ValueOf(f.functionWithInt).Call(args))
}
func (f functionWithIntFactory[K]) functionWithInt(int64) (ExprFunc[K], error) {
return func(context.Context, K) (interface{}, error) {
return "anything", nil
}, nil
}
# Helper function to handle the response of reflective calls to the function.
# Open to better names
func convert[K any](returnVals []reflect.Value) (ExprFunc[K], error) {
if returnVals[1].IsNil() {
return returnVals[0].Interface().(ExprFunc[K]), nil
}
return returnVals[0].Interface().(ExprFunc[K]), returnVals[1].Interface().(error)
} This attempt moves some of the logic that was hidden in |
I've implemented both solutions as separate commits in https://github.com/TylerHelmuth/opentelemetry-collector-contrib/tree/ottl-function-factories |
Playing around with my solution I realized we could assert the args to the type the functions require instead of using reflection to make the call func (f functionWithIntFactory[K]) CreateFunction(args []reflect.Value) (ExprFunc[K], error) {
return f.functionWithInt(args[0].Int())
}
func (f functionWithIntFactory[K]) functionWithInt(int64) (ExprFunc[K], error) {
return func(context.Context, K) (interface{}, error) {
return "anything", nil
}, nil
} When used in the context of OTTL's |
@TylerHelmuth I took some time to look at this, and I have a few thoughts about each solution presented here. I also took a look through your prototype implementations, thanks for writing those. Putting the functions on a structMy proposal to put the functions we have now on common (or individual) structs was intended as a simpler solution that forgoes some boilerplate at the cost of flexibility, in case we wanted something simpler. After some time to think about it, I now think that the added flexibility of using interfaces here is likely worth the additional boilerplate and we should probably go for a However, I still think it would be beneficial to have some more concrete direction on what problems we see this solving. I think the main benefit having all function factories implement Using a factory interface with an arguments structI have a few concerns about this approach, but I think we should be able to mostly address a few of the issues you brought up:
Struct field ordering is essential to struct type identity according to the Go specification, so I think it is safe to rely on. From the spec: "Two struct types are identical if they have the same sequence of fields [...]" However, I agree that putting the arguments into a struct doesn't feel intuitive, and it isn't immediately obvious that Go considers the ordering significant. The one redeeming factor here is that if the functions are all written like this, the pattern becomes more apparent. However it's still not obvious without first seeing that.
This should be avoidable by using reflection to determine whether the underlying type for Using a factory interface with methods on a structCompared to using an arguments struct, I like how your solution allows keeping the function signature and the functionality in a standard function definition. I think this is more conventional and should be more obvious for OTTL function authors compared to having the arguments in a struct. I also agree that having the definition outside a One concern I have is that compared to using an arguments struct, this approach seems to require more boilerplate on whole. I tried to play around with this a little bit and wasn't able to reduce it any further. Additionally, how do you see providing
I do like that this cuts down on some of the reflection calls and necessary translation. However, it's unfortunate that this would require duplicating the arguments list, so I'm not sure whether it is worth the trade-off. |
@evan-bradley thanks for the feedback.
In my first (and technically only) attempt at implementing the reflection using the argument struct I wasn't able to get Go to see the response of
Ya. I'd say applying any sort of structure/api to the function generation is gonna add boilerplate since the current solution is so bare-bones :/. Feels bad to add more complication to the process, but I think its main benefit will be handling common parameters. If we're looking for justification for why to do this I keep going back to For my attempt specifically I didn't add it (I think it would be added in a followup PR to keep the PRs smaller), but I think it could be done via I lean towards a separate function as it would prompt function creators to add the set of OTTL fields to their struct type OTTLFunctionFields struct {
ctx context.Context
settings component.TelemetrySettings
} Of course this is all even more boilerplate code 😭 |
I've implemented and tested a solution to use the I've split the work into two commits, one with the relevant changes and another to get everything to compile so I could test it. In my testing, I was able to process traces whether |
@evan-bradley nice! Does |
Thankfully no, that's just a convenience function so the user doesn't have to worry about whether the value returned from |
@evan-bradley how do you want to proceed? I'm still not in love with the idea of struct field order being important. Wanna discuss during a SIG meeting? |
I'm still undecided. I don't like the fact that we're defining arguments through a non-obvious and unconventional mechanism either, but I think using an arguments struct does have some advantages:
I think there are also a few factors that make it more palatable:
Sounds good to me. I've added it to the agenda for this week's meeting. |
You make a compelling argument. Lets hear more opinions at the meeting and then hopefully start implementing something. |
To summarize the discussion so far: We're looking to turn our current OTTL function factories into factory structs that implement a
From my perspective, the following questions are still open:
We have two proposals for how to implement this interface that differ on how to define the function signature of the DSL function.
There are merits to both approaches, but I've tried to outline some of the differences I see here. |
To summarize the discussion from today's SIG meeting, we will try an approach using an |
This issue has been inactive for 60 days. It will be closed in 60 days if there is no activity. To ping code owners by adding a component label, see Adding Labels via Comments, or if you are unsure of which component this issue relates to, please ping Pinging code owners:
See Adding Labels via Comments if you do not have permissions to add labels yourself. |
Describe the issue you're reporting
Right now we pass functions as interfaces, and then go over arguments using reflection, identifying some internal, etc. I think we should use a "factory" pattern similar with our components:
We will do the same "reflection" unmarshalling but for members of the
FunctionArguments
.Here is how for example DeleteKey:
This solution does not fix everything, but brings more structure to us, and allows in the future if needed to allow Factories to implement optional interfaces that will allow us to extend the functionality.
The text was updated successfully, but these errors were encountered: