diff --git a/.chloggen/add-actions-to-headerssetter.yaml b/.chloggen/add-actions-to-headerssetter.yaml new file mode 100644 index 000000000000..e88082fe2866 --- /dev/null +++ b/.chloggen/add-actions-to-headerssetter.yaml @@ -0,0 +1,24 @@ +# 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: headerssetter + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Extend the headers setter extension with header modification actions. + +# One or more tracking issues related to the change +issues: [16581, 7596] + +# (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: | + Please update configurations to use the action that suits your requirements: + - `insert`: Inserts the new header if it does not exist. + - `update`: Updates the header value if it exists. + - `upsert`: Inserts a header if it does not exist and updates the header + if it exists. + - `delete`: Deletes the header. + The default action is `upsert`, however, in future versions, we'll require this + to be explicitly set. diff --git a/extension/headerssetterextension/README.md b/extension/headerssetterextension/README.md index f084058fd1ab..6eac9c55e709 100644 --- a/extension/headerssetterextension/README.md +++ b/extension/headerssetterextension/README.md @@ -17,27 +17,40 @@ header to the value extracted from the context. The following settings are required: -- `headers`: a list of header configuration objects that specify headers and - their value sources. Each configuration object has the following properties: - - `key`: the header name - - `value`: the header value is looked up from the `value` property of the - extension configuration - - `from_context`: the header value is looked up from the request metadata, - such as HTTP headers, using the property value as the key (likely a header name) +- `headers`: a list of header configuration objects that specify headers and + their value sources. Each configuration object has the following properties: + - `key`: The header name. + - `action` (default: `upsert`): An action to perform on the header. Supported actions are: + - `insert`: Inserts the new header if it does not exist. + - `update`: Updates the header value if it exists. + - `upsert`: Inserts a header if it does not exist and updates the header + if it exists. + - `delete`: Deletes the header. + - `value`: The header value is looked up from the `value` property of the + extension configuration. + - `from_context`: The header value is looked up from the request metadata, + such as HTTP headers, using the property value as the key (likely a header + name). The `value` and `from_context` properties are mutually exclusive. - #### Configuration Example ```yaml extensions: headers_setter: headers: - - key: X-Scope-OrgID + - action: insert + key: X-Scope-OrgID from_context: tenant_id - - key: User-ID + - action: upsert + key: User-ID + value: user_id + - action: update + key: User-ID value: user_id + - action: delete + key: Some-Header receivers: otlp: diff --git a/extension/headerssetterextension/config.go b/extension/headerssetterextension/config.go index ddd91f19d32e..6221fc2f2886 100644 --- a/extension/headerssetterextension/config.go +++ b/extension/headerssetterextension/config.go @@ -30,11 +30,30 @@ type Config struct { } type HeaderConfig struct { - Key *string `mapstructure:"key"` - Value *string `mapstructure:"value"` - FromContext *string `mapstructure:"from_context"` + Action actionValue `mapstructure:"action"` + Key *string `mapstructure:"key"` + Value *string `mapstructure:"value"` + FromContext *string `mapstructure:"from_context"` } +// actionValue is the enum to capture the four types of actions to perform on a header +type actionValue string + +const ( + // INSERT inserts the new header if it does not exist + INSERT actionValue = "insert" + + // UPDATE updates the header value if it exists + UPDATE actionValue = "update" + + // UPSERT inserts a header if it does not exist and updates the header + // if it exists + UPSERT actionValue = "upsert" + + // DELETE deletes the header + DELETE actionValue = "delete" +) + // Validate checks if the extension configuration is valid func (cfg *Config) Validate() error { if cfg.HeadersConfig == nil || len(cfg.HeadersConfig) == 0 { @@ -44,11 +63,14 @@ func (cfg *Config) Validate() error { if header.Key == nil || *header.Key == "" { return errMissingHeader } - if header.FromContext == nil && header.Value == nil { - return errMissingSource - } - if header.FromContext != nil && header.Value != nil { - return errConflictingSources + + if header.Action != DELETE { + if header.FromContext == nil && header.Value == nil { + return errMissingSource + } + if header.FromContext != nil && header.Value != nil { + return errConflictingSources + } } } return nil diff --git a/extension/headerssetterextension/config_test.go b/extension/headerssetterextension/config_test.go index 30bc1e6b14fd..069f2b5cce1f 100644 --- a/extension/headerssetterextension/config_test.go +++ b/extension/headerssetterextension/config_test.go @@ -28,12 +28,13 @@ func TestLoadConfig(t *testing.T) { t.Parallel() tests := []struct { - id component.ID - expected component.Config + id component.ID + expected component.Config + expectedError error }{ { - id: component.NewID(typeStr), - expected: NewFactory().CreateDefaultConfig(), + id: component.NewIDWithName(typeStr, ""), + expectedError: errMissingHeadersConfig, }, { id: component.NewIDWithName(typeStr, "1"), @@ -41,14 +42,26 @@ func TestLoadConfig(t *testing.T) { HeadersConfig: []HeaderConfig{ { Key: stringp("X-Scope-OrgID"), + Action: INSERT, FromContext: stringp("tenant_id"), Value: nil, }, { Key: stringp("User-ID"), + Action: UPDATE, FromContext: stringp("user_id"), Value: nil, }, + + { + Key: stringp("User-ID"), + FromContext: nil, + Value: stringp("user_id"), + }, + { + Key: stringp("User-ID"), + Action: DELETE, + }, }, }, }, @@ -60,8 +73,15 @@ func TestLoadConfig(t *testing.T) { factory := NewFactory() cfg := factory.CreateDefaultConfig() sub, err := cm.Sub(tt.id.String()) + require.NoError(t, err) require.NoError(t, component.UnmarshalConfig(sub, cfg)) + + if tt.expectedError != nil { + assert.Error(t, component.ValidateConfig(cfg), tt.expectedError) + return + } + assert.NoError(t, component.ValidateConfig(cfg)) assert.Equal(t, tt.expected, cfg) }) } @@ -77,8 +97,9 @@ func TestValidateConfig(t *testing.T) { "header value from config property", []HeaderConfig{ { - Key: stringp("name"), - Value: stringp("from config"), + Key: stringp("name"), + Action: INSERT, + Value: stringp("from config"), }, }, nil, @@ -88,6 +109,7 @@ func TestValidateConfig(t *testing.T) { []HeaderConfig{ { Key: stringp("name"), + Action: INSERT, FromContext: stringp("from config"), }, }, @@ -96,14 +118,20 @@ func TestValidateConfig(t *testing.T) { { "missing header name for from value", []HeaderConfig{ - {Value: stringp("test")}, + { + Action: INSERT, + Value: stringp("test"), + }, }, errMissingHeader, }, { "missing header name for from context", []HeaderConfig{ - {FromContext: stringp("test")}, + { + Action: INSERT, + FromContext: stringp("test"), + }, }, errMissingHeader, }, @@ -112,6 +140,7 @@ func TestValidateConfig(t *testing.T) { []HeaderConfig{ { Key: stringp("name"), + Action: INSERT, Value: stringp("from config"), FromContext: stringp("from context"), }, @@ -122,11 +151,43 @@ func TestValidateConfig(t *testing.T) { "header value source is missing", []HeaderConfig{ { - Key: stringp("name"), + Key: stringp("name"), + Action: INSERT, }, }, errMissingSource, }, + { + "delete header action", + []HeaderConfig{ + { + Key: stringp("name"), + Action: DELETE, + }, + }, + nil, + }, + { + "insert header action", + []HeaderConfig{ + { + Key: stringp("name"), + Action: INSERT, + Value: stringp("from config"), + }, + }, + nil, + }, + { + "missing header action", + []HeaderConfig{ + { + Key: stringp("name"), + Value: stringp("from config"), + }, + }, + nil, + }, { "headers configuration is missing", nil, diff --git a/extension/headerssetterextension/extension.go b/extension/headerssetterextension/extension.go index d637af6caa55..b1843713274c 100644 --- a/extension/headerssetterextension/extension.go +++ b/extension/headerssetterextension/extension.go @@ -21,17 +21,19 @@ import ( "net/http" "go.opentelemetry.io/collector/extension/auth" + "go.uber.org/zap" "google.golang.org/grpc/credentials" + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/headerssetterextension/internal/action" "github.com/open-telemetry/opentelemetry-collector-contrib/extension/headerssetterextension/internal/source" ) type Header struct { - key string + action action.Action source source.Source } -func newHeadersSetterExtension(cfg *Config) (auth.Client, error) { +func newHeadersSetterExtension(cfg *Config, logger *zap.Logger) (auth.Client, error) { if cfg == nil { return nil, errors.New("extension configuration is not provided") } @@ -48,7 +50,23 @@ func newHeadersSetterExtension(cfg *Config) (auth.Client, error) { Key: *header.FromContext, } } - headers = append(headers, Header{key: *header.Key, source: s}) + + var a action.Action + switch header.Action { + case INSERT: + a = action.Insert{Key: *header.Key} + case UPSERT: + a = action.Upsert{Key: *header.Key} + case UPDATE: + a = action.Update{Key: *header.Key} + case DELETE: + a = action.Delete{Key: *header.Key} + default: + a = action.Upsert{Key: *header.Key} + logger.Warn("The action was not provided, using 'upsert'." + + " In future versions, we'll require this to be explicitly set") + } + headers = append(headers, Header{action: a, source: s}) } return auth.NewClient( @@ -84,7 +102,7 @@ func (h *headersPerRPC) GetRequestMetadata( if err != nil { return nil, fmt.Errorf("failed to determine the source: %w", err) } - metadata[header.key] = value + header.action.ApplyOnMetadata(metadata, value) } return metadata, nil } @@ -115,7 +133,7 @@ func (h *headersRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro if err != nil { return nil, fmt.Errorf("failed to determine the source: %w", err) } - req2.Header.Set(header.key, value) + header.action.ApplyOnHeaders(req2.Header, value) } return h.base.RoundTrip(req2) } diff --git a/extension/headerssetterextension/extension_test.go b/extension/headerssetterextension/extension_test.go index 8b4aec510cda..96b44f148e13 100644 --- a/extension/headerssetterextension/extension_test.go +++ b/extension/headerssetterextension/extension_test.go @@ -36,7 +36,7 @@ func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) func TestRoundTripper(t *testing.T) { for _, tt := range tests { t.Run("round_tripper", func(t *testing.T) { - ext, err := newHeadersSetterExtension(tt.cfg) + ext, err := newHeadersSetterExtension(tt.cfg, nil) assert.NoError(t, err) assert.NotNil(t, ext) @@ -71,7 +71,7 @@ func TestRoundTripper(t *testing.T) { func TestPerRPCCredentials(t *testing.T) { for _, tt := range tests { t.Run("", func(t *testing.T) { - ext, err := newHeadersSetterExtension(tt.cfg) + ext, err := newHeadersSetterExtension(tt.cfg, nil) assert.NoError(t, err) assert.NotNil(t, ext) @@ -112,6 +112,7 @@ var ( HeadersConfig: []HeaderConfig{ { Key: &header, + Action: INSERT, FromContext: stringp("tenant"), }, }, @@ -127,8 +128,9 @@ var ( cfg: &Config{ HeadersConfig: []HeaderConfig{ { - Key: &header, - Value: stringp("config value"), + Key: &header, + Action: INSERT, + Value: stringp("config value"), }, }, }, @@ -141,10 +143,12 @@ var ( HeadersConfig: []HeaderConfig{ { Key: &header, + Action: INSERT, FromContext: stringp("tenant"), }, { Key: &anotherHeader, + Action: INSERT, FromContext: stringp("tenant"), }, }, @@ -162,6 +166,7 @@ var ( HeadersConfig: []HeaderConfig{ { Key: &header, + Action: INSERT, FromContext: stringp(""), }, }, @@ -174,8 +179,9 @@ var ( cfg: &Config{ HeadersConfig: []HeaderConfig{ { - Key: &header, - Value: stringp(""), + Key: &header, + Action: INSERT, + Value: stringp(""), }, }, }, @@ -188,10 +194,12 @@ var ( HeadersConfig: []HeaderConfig{ { Key: &header, + Action: INSERT, FromContext: stringp("tenant"), }, { Key: &anotherHeader, + Action: INSERT, FromContext: stringp("tenant_"), }, }, @@ -209,6 +217,7 @@ var ( HeadersConfig: []HeaderConfig{ { Key: &header, + Action: INSERT, FromContext: stringp("tenant_"), }, }, diff --git a/extension/headerssetterextension/factory.go b/extension/headerssetterextension/factory.go index 5317ba092fda..346e5320241d 100644 --- a/extension/headerssetterextension/factory.go +++ b/extension/headerssetterextension/factory.go @@ -41,8 +41,8 @@ func createDefaultConfig() component.Config { func createExtension( _ context.Context, - _ extension.CreateSettings, + settings extension.CreateSettings, cfg component.Config, ) (extension.Extension, error) { - return newHeadersSetterExtension(cfg.(*Config)) + return newHeadersSetterExtension(cfg.(*Config), settings.Logger) } diff --git a/extension/headerssetterextension/go.mod b/extension/headerssetterextension/go.mod index 81dbd9920735..7c7d772e8720 100644 --- a/extension/headerssetterextension/go.mod +++ b/extension/headerssetterextension/go.mod @@ -7,6 +7,7 @@ require ( go.opentelemetry.io/collector v0.70.0 go.opentelemetry.io/collector/component v0.70.0 go.opentelemetry.io/collector/confmap v0.70.0 + go.uber.org/zap v1.24.0 google.golang.org/grpc v1.52.1 ) @@ -24,7 +25,6 @@ require ( go.opentelemetry.io/otel/trace v1.11.2 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect - go.uber.org/zap v1.24.0 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/extension/headerssetterextension/internal/action/action.go b/extension/headerssetterextension/internal/action/action.go new file mode 100644 index 000000000000..045595754bdd --- /dev/null +++ b/extension/headerssetterextension/internal/action/action.go @@ -0,0 +1,78 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/headerssetterextension/internal/action" + +import "net/http" + +type Action interface { + ApplyOnHeaders(http.Header, string) + ApplyOnMetadata(map[string]string, string) +} + +type Insert struct { + Key string +} + +func (a Insert) ApplyOnHeaders(header http.Header, value string) { + if v := header.Get(a.Key); v == "" { + header.Set(a.Key, value) + } +} + +func (a Insert) ApplyOnMetadata(metadata map[string]string, value string) { + if _, ok := metadata[a.Key]; !ok { + metadata[a.Key] = value + } +} + +type Update struct { + Key string +} + +func (a Update) ApplyOnHeaders(header http.Header, value string) { + if v := header.Get(a.Key); v != "" { + header.Set(a.Key, value) + } +} + +func (a Update) ApplyOnMetadata(metadata map[string]string, value string) { + if _, ok := metadata[a.Key]; ok { + metadata[a.Key] = value + } +} + +type Delete struct { + Key string +} + +func (a Delete) ApplyOnHeaders(header http.Header, _ string) { + header.Del(a.Key) +} + +func (a Delete) ApplyOnMetadata(metadata map[string]string, _ string) { + delete(metadata, a.Key) +} + +type Upsert struct { + Key string +} + +func (a Upsert) ApplyOnHeaders(header http.Header, value string) { + header.Set(a.Key, value) +} + +func (a Upsert) ApplyOnMetadata(metadata map[string]string, value string) { + metadata[a.Key] = value +} diff --git a/extension/headerssetterextension/internal/action/action_test.go b/extension/headerssetterextension/internal/action/action_test.go new file mode 100644 index 000000000000..6328793cbce3 --- /dev/null +++ b/extension/headerssetterextension/internal/action/action_test.go @@ -0,0 +1,218 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInsertAction(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + value string + header http.Header + metadata map[string]string + expectedHeader http.Header + expectedMetadata map[string]string + }{ + { + key: "Key", + value: "value", + header: http.Header{}, + metadata: map[string]string{}, + expectedHeader: map[string][]string{"Key": {"value"}}, + expectedMetadata: map[string]string{"Key": "value"}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"AnotherKey": {"value"}}, + metadata: map[string]string{"AnotherKey": "value"}, + expectedHeader: http.Header{"AnotherKey": []string{"value"}, "Key": []string{"value"}}, + expectedMetadata: map[string]string{"AnotherKey": "value", "Key": "value"}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"Key": {"different value"}}, + metadata: map[string]string{"Key": "different value"}, + expectedHeader: map[string][]string{"Key": {"different value"}}, + expectedMetadata: map[string]string{"Key": "different value"}, + }, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + action := Insert{Key: tt.key} + + action.ApplyOnHeaders(tt.header, tt.value) + action.ApplyOnMetadata(tt.metadata, tt.value) + + assert.Equal(t, tt.expectedHeader, tt.header) + assert.Equal(t, tt.expectedMetadata, tt.metadata) + }) + } +} + +func TestUpdateAction(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + value string + header http.Header + metadata map[string]string + expectedHeader http.Header + expectedMetadata map[string]string + }{ + { + key: "Key", + value: "value", + header: http.Header{}, + metadata: map[string]string{}, + expectedHeader: map[string][]string{}, + expectedMetadata: map[string]string{}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"AnotherKey": {"value"}}, + metadata: map[string]string{"AnotherKey": "value"}, + expectedHeader: http.Header{"AnotherKey": []string{"value"}}, + expectedMetadata: map[string]string{"AnotherKey": "value"}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"Key": {"different value"}}, + metadata: map[string]string{"Key": "different value"}, + expectedHeader: map[string][]string{"Key": {"value"}}, + expectedMetadata: map[string]string{"Key": "value"}, + }, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + action := Update{Key: tt.key} + + action.ApplyOnHeaders(tt.header, tt.value) + action.ApplyOnMetadata(tt.metadata, tt.value) + + assert.Equal(t, tt.expectedHeader, tt.header) + assert.Equal(t, tt.expectedMetadata, tt.metadata) + }) + } +} + +func TestUpsertAction(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + value string + header http.Header + metadata map[string]string + expectedHeader http.Header + expectedMetadata map[string]string + }{ + { + key: "Key", + value: "value", + header: http.Header{}, + metadata: map[string]string{}, + expectedHeader: map[string][]string{"Key": {"value"}}, + expectedMetadata: map[string]string{"Key": "value"}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"AnotherKey": {"value"}}, + metadata: map[string]string{"AnotherKey": "value"}, + expectedHeader: http.Header{"AnotherKey": []string{"value"}, "Key": []string{"value"}}, + expectedMetadata: map[string]string{"AnotherKey": "value", "Key": "value"}, + }, + { + key: "Key", + value: "value", + header: map[string][]string{"Key": {"different value"}}, + metadata: map[string]string{"Key": "different value"}, + expectedHeader: map[string][]string{"Key": {"value"}}, + expectedMetadata: map[string]string{"Key": "value"}, + }, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + action := Upsert{Key: tt.key} + + action.ApplyOnHeaders(tt.header, tt.value) + action.ApplyOnMetadata(tt.metadata, tt.value) + + assert.Equal(t, tt.expectedHeader, tt.header) + assert.Equal(t, tt.expectedMetadata, tt.metadata) + }) + } +} + +func TestDeleteAction(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + header http.Header + metadata map[string]string + expectedHeader http.Header + expectedMetadata map[string]string + }{ + { + key: "Key", + header: http.Header{}, + metadata: map[string]string{}, + expectedHeader: map[string][]string{}, + expectedMetadata: map[string]string{}, + }, + { + key: "Key", + header: map[string][]string{"AnotherKey": {"value"}, "Key": {"value"}}, + metadata: map[string]string{"AnotherKey": "value", "Key": "value"}, + expectedHeader: http.Header{"AnotherKey": []string{"value"}}, + expectedMetadata: map[string]string{"AnotherKey": "value"}, + }, + { + key: "Key", + header: map[string][]string{"Key": {"different value"}}, + metadata: map[string]string{"Key": "different value"}, + expectedHeader: map[string][]string{}, + expectedMetadata: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + action := Delete{Key: tt.key} + + action.ApplyOnHeaders(tt.header, "") + action.ApplyOnMetadata(tt.metadata, "") + + assert.Equal(t, tt.expectedHeader, tt.header) + assert.Equal(t, tt.expectedMetadata, tt.metadata) + }) + } +} diff --git a/extension/headerssetterextension/testdata/config.yaml b/extension/headerssetterextension/testdata/config.yaml index 40a51552c949..05dfe5403773 100644 --- a/extension/headerssetterextension/testdata/config.yaml +++ b/extension/headerssetterextension/testdata/config.yaml @@ -2,6 +2,12 @@ headers_setter: headers_setter/1: headers: - key: X-Scope-OrgID + action: insert from_context: "tenant_id" - key: User-ID + action: update from_context: "user_id" + - key: User-ID + value: "user_id" + - key: User-ID + action: delete