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

[chore] Create RFC for Optional config types #12596

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
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
276 changes: 276 additions & 0 deletions docs/rfcs/optional-config-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
# Optional[T] type for use in configuration structs

## Overview

In Go, types are by default set to a "zero-value", a supported value for the
respective type that is semantically equivalent or similar to "empty", but which
is also a valid value for the type. For many config fields, the zero value is
not a valid configuration value and can be taken to mean that the option is
disabled, but in certain cases it can indicate a default value, necessitating a
way to represent the presence of a value without using a valid value for the
type.

Using standard Go without inventing any new types, the two most straightforward
ways to accomplish this are:

1. Using a separate boolean field to indicate whether the field is enabled or
disabled.
2. Making the type a pointer, which makes a `nil` pointer represent that a
value is not present, and a valid pointer represent the desired value.

Each of these approaches has deficiencies: Using a separate boolean field
requires the user to set the boolean field to `true` in addition to setting the
actual config option, leading to suboptimal UX. Using a pointer value has a few
drawbacks:

1. It may not be immediately obvious to a new user that a pointer type indicates
a field is optional.
2. The distinction between values that are conventionally pointers (e.g. gRPC
configs) and optional values is lost.
3. Setting a default value for a pointer field when decoding will set the field
on the resulting config struct, and additional logic must be done to unset
the default if the user has not specified a value.
4. The potential for null pointer exceptions is created.
5. Config structs are generally intended to be immutable and may be passed
around a lot, which makes the mutability property of pointer fields
an undesirable property.
Comment on lines +28 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

Drawback numbers 2 and 5 appear contradictory. Is there a reason why pointers are "conventionally" pointers, aside from mutability?

Suggested change
2. The distinction between values that are conventionally pointers (e.g. gRPC
configs) and optional values is lost.
3. Setting a default value for a pointer field when decoding will set the field
on the resulting config struct, and additional logic must be done to unset
the default if the user has not specified a value.
4. The potential for null pointer exceptions is created.
5. Config structs are generally intended to be immutable and may be passed
around a lot, which makes the mutability property of pointer fields
an undesirable property.
2. While they can be copied cheaply, pointers convey a mutable reference making
them unsafe to share.
3. Setting a default value for a pointer field when decoding will set the field
on the resulting config struct, and additional logic must be done to unset
the default if the user has not specified a value.
4. The potential for null pointer exceptions is created.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This may not really apply to config structs, but outside mutability, another reason to use a pointer type for a field in Go is so you don't have to pass around a large struct by value.

The specific point I was making with item 2 is that some types in popular libraries are pointers, e.g. *zap.Logger, *grpc.Server, etc. If pointers are used to indicate optional values, if you have a field that is a *zap.Logger type, it's difficult to tell if it's intentionally optional, or if that type is almost always used as a pointer type (zap.Logger as a non-pointer is rare from what I've seen). Obviously *zap.Logger isn't a perfect example because nobody is setting a logger in YAML, but hopefully my point still makes sense.


## Optional types

Go does not include any form of Optional type in the standard library, but other
popular languages like Rust and Java do. We could implement something similar in
our config packages that allows us to address the downsides of using pointers
for optional config fields.

## Basic definition

A production-grade implementation will not be discussed or shown here, but the
basic implementation for an Optional type in Go could look something like:

```golang
type Optional[T any] struct {
hasValue bool
value T

defaultVal T
}

func Some[T any](value T) Optional[T] {
return Optional[T]{value: value, hasValue: true}
}

func None[T any](value T) Optional[T] {
return Optional[T]{}
}

func WithDefault[T any](defaultVal T) Optional[T] {
return Optional[T]{defaultVal: defaultVal}
}

func (o Optional[T]) HasValue() bool {
return o.hasValue
}

func (o Optional[T]) Value() T {
return o.value
}

func (o Optional[T]) Default() T {
return o.defaultVal
}
```

## Use cases

Optional types can fulfill the following use cases we have when decoding config.

### Representing optional config fields

To use the optional type to mark a config field as optional, the type can simply
be used as a type parameter to `Optional[T]`. The following config struct shows
how this may look, both in definition and in usage:

```golang
Copy link
Member

Choose a reason for hiding this comment

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

More than golang code, would be good to show how users in yaml will use it.

Copy link
Member

Choose a reason for hiding this comment

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

there's no expected impact on YAML

type Protocols struct {
GRPC Optional[configgrpc.ServerConfig] `mapstructure:"grpc"`
HTTP Optional[HTTPConfig] `mapstructure:"http"`
}

func (cfg *Config) Validate() error {
if !cfg.GRPC.HasValue() && !cfg.HTTP.HasValue() {
return errors.New("must specify at least one protocol when using the OTLP receiver")
}
return nil
}

func createDefaultConfig() component.Config {
return &Config{
Protocols: Protocols{
GRPC: WithDefault(configgrpc.ServerConfig{
// ...
}),
HTTP: WithDefault(HTTPConfig{
// ...
}),
},
}
}
```

For something like `confighttp.ServerConfig`, using an `Optional[T]` type for
optional fields would look like this:

```golang
type ServerConfig struct {
TLSSetting Optional[configtls.ServerConfig] `mapstructure:"tls"`

CORS Optional[CORSConfig] `mapstructure:"cors"`

Auth Optional[AuthConfig] `mapstructure:"auth,omitempty"`

ResponseHeaders Optional[map[string]configopaque.String] `mapstructure:"response_headers"`
}

func NewDefaultServerConfig() ServerConfig {
return ServerConfig{
TLSSetting: WithDefault(configtls.NewDefaultServerConfig()),
CORS: WithDefault(NewDefaultCORSConfig()),
WriteTimeout: 30 * time.Second,
ReadHeaderTimeout: 1 * time.Minute,
IdleTimeout: 1 * time.Minute,
}
}

func (hss *ServerConfig) ToListener(ctx context.Context) (net.Listener, error) {
listener, err := net.Listen("tcp", hss.Endpoint)
if err != nil {
return nil, err
}

if hss.TLSSetting.HasValue() {
var tlsCfg *tls.Config
tlsCfg, err = hss.TLSSetting.Value().LoadTLSConfig(ctx)
if err != nil {
return nil, err
}
tlsCfg.NextProtos = []string{http2.NextProtoTLS, "http/1.1"}
listener = tls.NewListener(listener, tlsCfg)
}

return listener, nil
}

func (hss *ServerConfig) ToServer(_ context.Context, host component.Host, settings component.TelemetrySettings, handler http.Handler, opts ...ToServerOption) (*http.Server, error) {
// ...

handler = httpContentDecompressor(
handler,
hss.MaxRequestBodySize,
serverOpts.ErrHandler,
hss.CompressionAlgorithms,
serverOpts.Decoders,
)

// ...

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.Value().RequestParameters)
}

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

if hss.ResponseHeaders.HasValue() {
handler = responseHeadersHandler(handler, hss.ResponseHeaders.Value())
}

// ...
}
```

### Proper unmarshaling of empty values when a default is set

Currently, the OTLP receiver requires a workaround to make enabling each
protocol optional while providing defaults if a key for the protocol has been
set:

```golang
type Protocols struct {
GRPC *configgrpc.ServerConfig `mapstructure:"grpc"`
HTTP *HTTPConfig `mapstructure:"http"`
}

// Config defines configuration for OTLP receiver.
type Config struct {
// Protocols is the configuration for the supported protocols, currently gRPC and HTTP (Proto and JSON).
Protocols `mapstructure:"protocols"`
}

func createDefaultConfig() component.Config {
return &Config{
Protocols: Protocols{
GRPC: configgrpc.NewDefaultServerConfig(),
HTTP: &HTTPConfig{
// ...
},
},
}
}

func (cfg *Config) Unmarshal(conf *confmap.Conf) error {
// first load the config normally
err := conf.Unmarshal(cfg)
if err != nil {
return err
}

// gRPC will be enabled if this line is not run
if !conf.IsSet(protoGRPC) {
cfg.GRPC = nil
}

// HTTP will be enabled if this line is not run
if !conf.IsSet(protoHTTP) {
cfg.HTTP = nil
}

return nil
}
```

With an Optional type, the checks in `Unmarshal` become unnecessary, and it's
possible the entire `Unmarshal` function may no longer be needed. Instead, when
the config is unmarshaled, no value would be put into the default Optional
values and `HasValue` would return false when using this config object.

This situation is something of an edge case with our current unmarshaling
facilities and while not common, could be a rough edge for users looking to
implement similar config structures.

## Disadvantages of an Optional type

There is one noteworthy disadvantage of introducing an Optional type:

1. Since the type isn't standard, external packages working with config may
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the intended implementation plan for Optional config types? In the above example, it says:

This situation is something of an edge case

So is the intention to only modify fields who meet some required use case? Or is it to modify all fields that are currently pointers?

Changing the field type is a breaking API change. Is there a different approach depending on the stability level of a component? Or is the expectation that anyone importing and using a config struct from an otel component will have to make any required changes given the new struct field types?

Copy link
Member

Choose a reason for hiding this comment

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

@kristinapathak Do you have examples of components/modules that are 1.x and need to use this optional type? We are blocking confighttp/configgrpc on this so that we can apply this pattern on these modules.

The intent is to apply this new type everywhere, go through an (API) breaking change, and ensure this type is used everywhere going forward.

require additional adaptations to work with our config structs. For example,
if we wanted to generate our types from a JSON schema using a package like
[github.com/atombender/go-jsonschema][go-jsonschema], we would need some way

Check warning on line 273 in docs/rfcs/optional-config-type.md

View workflow job for this annotation

GitHub Actions / spell-check

Unknown word (atombender)
to ensure compatibility with an Optional type.

[go-jsonschema]: https://github.com/omissis/go-jsonschema
Loading