Skip to content

[Do Not Merge] POC optional hook #11409

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

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions collector.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
receivers:
otlp:
protocols:
http:
endpoint: localhost:4317
cors:
allowed_origins:
- http://test.com
allowed_headers:
- Example-Header
max_age: 7200

exporters:
otlphttp:
endpoint: localhost:4318
max_idle_conns: 567

service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp]
60 changes: 36 additions & 24 deletions config/confighttp/confighttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"go.opentelemetry.io/collector/config/configtelemetry"
"go.opentelemetry.io/collector/config/configtls"
"go.opentelemetry.io/collector/config/internal"
"go.opentelemetry.io/collector/confmap/optional"
"go.opentelemetry.io/collector/extension/auth"
)

Expand Down Expand Up @@ -71,11 +72,11 @@ type ClientConfig struct {

// MaxIdleConns is used to set a limit to the maximum idle HTTP connections the client can keep open.
// By default, it is set to 100.
MaxIdleConns *int `mapstructure:"max_idle_conns"`
MaxIdleConns optional.Optional[int] `mapstructure:"max_idle_conns"`

// MaxIdleConnsPerHost is used to set a limit to the maximum idle HTTP connections the host can keep open.
// By default, it is set to [http.DefaultTransport.MaxIdleConnsPerHost].
MaxIdleConnsPerHost *int `mapstructure:"max_idle_conns_per_host"`
MaxIdleConnsPerHost optional.Optional[int] `mapstructure:"max_idle_conns_per_host"`

// MaxConnsPerHost limits the total number of connections per host, including connections in the dialing,
// active, and idle states.
Expand Down Expand Up @@ -122,11 +123,14 @@ func NewDefaultClientConfig() ClientConfig {
defaultTransport := http.DefaultTransport.(*http.Transport)

return ClientConfig{
ReadBufferSize: defaultTransport.ReadBufferSize,
WriteBufferSize: defaultTransport.WriteBufferSize,
Headers: map[string]configopaque.String{},
MaxIdleConns: &defaultTransport.MaxIdleConns,
MaxIdleConnsPerHost: &defaultTransport.MaxIdleConnsPerHost,
ReadBufferSize: defaultTransport.ReadBufferSize,
WriteBufferSize: defaultTransport.WriteBufferSize,
Headers: map[string]configopaque.String{},
MaxIdleConns: optional.Optional[int]{
Value: 12345,
HasValue: true,
},
MaxIdleConnsPerHost: optional.Optional[int]{},
MaxConnsPerHost: &defaultTransport.MaxConnsPerHost,
IdleConnTimeout: &defaultTransport.IdleConnTimeout,
}
Expand All @@ -149,12 +153,14 @@ func (hcs *ClientConfig) ToClient(ctx context.Context, host component.Host, sett
transport.WriteBufferSize = hcs.WriteBufferSize
}

if hcs.MaxIdleConns != nil {
transport.MaxIdleConns = *hcs.MaxIdleConns
fmt.Printf("MaxIdleConns: %+v\n", hcs.MaxIdleConns)
if hcs.MaxIdleConns.HasValue {
transport.MaxIdleConns = hcs.MaxIdleConns.Value
}

if hcs.MaxIdleConnsPerHost != nil {
transport.MaxIdleConnsPerHost = *hcs.MaxIdleConnsPerHost
fmt.Printf("MaxIdleConnsPerHost: %+v\n", hcs.MaxIdleConnsPerHost)
if hcs.MaxIdleConnsPerHost.HasValue {
transport.MaxIdleConnsPerHost = hcs.MaxIdleConnsPerHost.Value
}

if hcs.MaxConnsPerHost != nil {
Expand Down Expand Up @@ -279,10 +285,10 @@ type ServerConfig struct {
TLSSetting *configtls.ServerConfig `mapstructure:"tls"`

// CORS configures the server for HTTP cross-origin resource sharing (CORS).
CORS *CORSConfig `mapstructure:"cors"`
CORS optional.Optional[CORSConfig] `mapstructure:"cors"`

// Auth for this receiver
Auth *AuthConfig `mapstructure:"auth"`
Auth optional.Optional[AuthConfig] `mapstructure:"auth"`

// MaxRequestBodySize sets the maximum request body size in bytes. Default: 20MiB.
MaxRequestBodySize int64 `mapstructure:"max_request_body_size"`
Expand Down Expand Up @@ -334,9 +340,12 @@ type ServerConfig struct {
func NewDefaultServerConfig() ServerConfig {
tlsDefaultServerConfig := configtls.NewDefaultServerConfig()
return ServerConfig{
ResponseHeaders: map[string]configopaque.String{},
TLSSetting: &tlsDefaultServerConfig,
CORS: NewDefaultCORSConfig(),
ResponseHeaders: map[string]configopaque.String{},
TLSSetting: &tlsDefaultServerConfig,
CORS: optional.Optional[CORSConfig]{
Value: *NewDefaultCORSConfig(),
HasValue: true,
},
WriteTimeout: 30 * time.Second,
ReadHeaderTimeout: 1 * time.Minute,
IdleTimeout: 1 * time.Minute,
Expand Down Expand Up @@ -433,26 +442,29 @@ func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settin
handler = maxRequestBodySizeInterceptor(handler, hss.MaxRequestBodySize)
}

if hss.Auth != nil {
server, err := hss.Auth.GetServerAuthenticator(context.Background(), host.GetExtensions())
fmt.Printf("AUTH: %+v\n", hss.Auth)
if hss.Auth.HasValue {
server, err := hss.Auth.Value.GetServerAuthenticator(context.Background(), host.GetExtensions())
if err != nil {
return nil, err
}

handler = authInterceptor(handler, server, hss.Auth.RequestParameters)
handler = authInterceptor(handler, server, hss.Auth.Value.RequestParameters)
}

if hss.CORS != nil && len(hss.CORS.AllowedOrigins) > 0 {
fmt.Printf("CORS: %+v\n", hss.CORS)
if hss.CORS.HasValue && len(hss.CORS.Value.AllowedOrigins) > 0 {
co := cors.Options{
AllowedOrigins: hss.CORS.AllowedOrigins,
AllowedOrigins: hss.CORS.Value.AllowedOrigins,
AllowCredentials: true,
AllowedHeaders: hss.CORS.AllowedHeaders,
MaxAge: hss.CORS.MaxAge,
AllowedHeaders: hss.CORS.Value.AllowedHeaders,
MaxAge: hss.CORS.Value.MaxAge,
}
handler = cors.New(co).Handler(handler)
}
if hss.CORS != nil && len(hss.CORS.AllowedOrigins) == 0 && len(hss.CORS.AllowedHeaders) > 0 {
if hss.CORS.HasValue && len(hss.CORS.Value.AllowedOrigins) == 0 && len(hss.CORS.Value.AllowedHeaders) > 0 {
settings.Logger.Warn("The CORS configuration specifies allowed headers but no allowed origins, and is therefore ignored.")

}

if hss.ResponseHeaders != nil {
Expand Down
9 changes: 9 additions & 0 deletions config/confighttp/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
go.opentelemetry.io/collector/config/configtelemetry v0.111.0
go.opentelemetry.io/collector/config/configtls v1.17.0
go.opentelemetry.io/collector/config/internal v0.111.0
go.opentelemetry.io/collector/confmap v0.0.0-00010101000000-000000000000
go.opentelemetry.io/collector/extension/auth v0.111.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0
go.opentelemetry.io/otel v1.30.0
Expand All @@ -31,8 +32,14 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.1.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/koanf/providers/confmap v0.1.0 // indirect
github.com/knadh/koanf/v2 v2.1.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opentelemetry.io/collector/extension v0.111.0 // indirect
go.opentelemetry.io/collector/pdata v1.17.0 // indirect
Expand All @@ -50,6 +57,8 @@ require (

replace go.opentelemetry.io/collector/config/configauth => ../configauth

replace go.opentelemetry.io/collector/confmap => ../../confmap

replace go.opentelemetry.io/collector/config/configcompression => ../configcompression

replace go.opentelemetry.io/collector/config/configopaque => ../configopaque
Expand Down
12 changes: 12 additions & 0 deletions config/confighttp/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions confmap/confmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ func decodeConfig(m *Conf, result any, errorUnused bool, skipTopLevelUnmarshaler
WeaklyTypedInput: false,
MatchName: caseSensitiveMatchName,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
optionalHookFunc(),
useExpandValue(),
expandNilStructPointersHookFunc(),
mapstructure.StringToSliceHookFunc(","),
Expand Down Expand Up @@ -431,6 +432,40 @@ func unmarshalerEmbeddedStructsHookFunc() mapstructure.DecodeHookFuncValue {
}
}

// optionalHookFunc applies logic when the to.Type is optional.Optional. When decoding primitive types,
// we use reflect.Value's FieldByName API (https://pkg.go.dev/reflect#Value.FieldByName) and Set (https://pkg.go.dev/reflect#Value.Set) APIs
// in order to set the Value and HasValue. Set cannot be used for struct field types, as the set value must be assignable to the target type,
// so for structs we use optionals custom unmarshaller in order to cast the map to the struct.
//
// This logic relies on the fact that the hook only gets called when the value is explicitely set in the config,
// so if we are in this hook, we know we can set HasValue to true.
func optionalHookFunc() mapstructure.DecodeHookFuncValue {
return func(from reflect.Value, to reflect.Value) (any, error) {
// As the type is generic, we check for the prefix.
if strings.HasPrefix(to.Type().String(), "optional.Optional") {
// the optional.Optional field is a struct
if _, ok := from.Interface().(map[string]any); ok {
unmarshaler, ok := to.Addr().Interface().(Unmarshaler)
if !ok {
return from.Interface(), nil
}
c := NewFromStringMap(from.Interface().(map[string]any))
if err := unmarshaler.Unmarshal(c); err != nil {
return nil, err
}
return to.Interface(), nil
}

// the optional.Optional field is a primitive type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just spitballing here, but would it be possible to instead have an interface specifically for unmarshalling primitive values? For example, maybe someone would want to do the following:

type EsotericIntEncoding int

func (e *EsotericIntEncoding) UnmarshalPrimitive(val any) error {
	x, ok := val.(int)
	if !ok {
		return errors.New("not an int")
	}
	*e = EsotericIntEncoding(x)
	return nil
}

We could then check if from is not a map and if to implements the PrimitiveUnmarshaler interface and unmarshal things like Optional[int] this way. The interface would also let component authors unmarshal custom types that alias primitive values like EsotericIntEncoding.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also allow the Optional type to handle this value itself and to avoid needing to make its fields public so they can be accessed by reflection.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just spitballing here, but would it be possible to instead have an interface specifically for unmarshalling primitive values

I'm not sure to understand how this would work:

  • Seems like the receiver would need to be Optional[T] if we want to set the hasValue field ?
  • With this approach, would we need to have a separate UnmarshalPrimitive func for each type we want to support ?

This would also allow the Optional type to handle this value itself and to avoid needing to make its fields public so they can be accessed by reflection.

I've made changes that unexport the Optional structs fields, by using a custom unmarshaller.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the receiver would need to be Optional[T] if we want to set the hasValue field ?

Yeah, I think an *Optional[T] receiver is the way to go. The way you currently have it looks good to me.

With this approach, would we need to have a separate UnmarshalPrimitive func for each type we want to support ?

Yes, I think it could help with other primitive types (e.g. special encodings for strings, ints, etc.) in addition to helping here with Optional[T] where T is a primitive type. It also means we don't need a special hook for only our type if we put logic like the following in unmarshalerHookFunk:

if _, ok = from.Interface().(map[string]any); !ok {
	unmarshaler, ok := toPtr.(PrimitiveUnmarshaler)
	if !ok {
		return from.Interface(), nil
	}
	if err := unmarshaler.UnmarshalPrimitive(from.Interface()); err != nil {
		return nil, err
	}
	return unmarshaler, nil
}

I tested this locally and it appears to work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks for the explanation! I've removed the optional hook and moved all logic to unmarshalerHookFunk in last commit

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can clean up the PR and add tests if we choose to go with this approach. Do we need other reviewers ? perhaps @mx-psi or @yurishkuro ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like input from both of them before continuing. We should also check whether @yurishkuro would like to continue work on #10260, since that was opened before this.

If you have time, would you be willing to add/update some basic tests, or report on the output of manually running the Collector with these changes, to show that this is viable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will mention as an aside that I looked at encoding.TextUnmarshaler as an alternative that wouldn't require us to provide a new interface, but ultimately we want to use the string -> primitive type unmarshaling provided by mapstructure, so in the end I think this would add more complexity than it removes. Implementing our own interface allows us to accept partially-unmarshaled values and do any final transformations on them.

The downside here is that encoding.TextUnmarshaler and our PrimitiveUnmarshaler interfaces would have some overlap, and users would need to know which one to use. I also don't have any immediate use-cases for PrimitiveUnmarshaler in mind outside this optional type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have time, would you be willing to add/update some basic tests, or report on the output of manually running the Collector with these changes, to show that this is viable?

I added a collector.yaml and print statements in this PR which demonstrates the results, see output:

2024-10-16T14:48:51.880+0200	info	service/service.go:135	Setting up own telemetry...
2024-10-16T14:48:51.880+0200	info	telemetry/metrics.go:70	Serving metrics	{"address": "localhost:8888", "metrics level": "Normal"}
2024-10-16T14:48:51.881+0200	info	service/service.go:207	Starting otelcorecol...	{"Version": "0.111.0-dev", "NumCPU": 10}
2024-10-16T14:48:51.881+0200	info	extensions/extensions.go:39	Starting extensions...
MaxIdleConns: {value:567 hasValue:true}
MaxIdleConnsPerHost: {value:0 hasValue:false}
AUTH: {value:{Authentication:{AuthenticatorID:{typeVal:{name:} nameVal:}} RequestParameters:[]} hasValue:false}
CORS: {value:{AllowedOrigins:[http://test.com] AllowedHeaders:[Example-Header] MaxAge:7200} hasValue:true}
2024-10-16T14:48:51.881+0200	info	otlpreceiver/otlp.go:169	Starting HTTP server	{"kind": "receiver", "name": "otlp", "data_type": "traces", "endpoint": "localhost:4317"}
2024-10-16T14:48:51.881+0200	info	service/service.go:230	Everything is ready. Begin running and processing data.

let me know if this is enough, or if I should still add tests ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also check whether @yurishkuro would like to continue work on #10260, since that was opened before this.

Happy to close this in favor of Yuri's PR. The only additions/substraction I made here:

to.FieldByName("Value").Set(from)
to.FieldByName("HasValue").SetBool(true)
return to.Interface(), nil
}

return from.Interface(), nil
}
}

// Provides a mechanism for individual structs to define their own unmarshal logic,
// by implementing the Unmarshaler interface, unless skipTopLevelUnmarshaler is
// true and the struct matches the top level object being unmarshaled.
Expand Down
16 changes: 16 additions & 0 deletions confmap/optional/optional.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package optional

import "go.opentelemetry.io/collector/confmap"

type Optional[T any] struct {
Value T
HasValue bool
}

func (o *Optional[T]) Unmarshal(conf *confmap.Conf) error {
o.HasValue = true
if err := conf.Unmarshal(&o.Value); err != nil {
return err
}
return nil
}
Loading