diff --git a/.chloggen/pkg-ottl-add-parse-key-value-function.yaml b/.chloggen/pkg-ottl-add-parse-key-value-function.yaml new file mode 100644 index 000000000000..c6a5b206dcde --- /dev/null +++ b/.chloggen/pkg-ottl-add-parse-key-value-function.yaml @@ -0,0 +1,27 @@ +# 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 `ParseKeyValue` function for parsing key value pairs from a target string + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [30998] + +# (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: + +# 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: [] diff --git a/internal/coreinternal/parseutils/doc.go b/internal/coreinternal/parseutils/doc.go new file mode 100644 index 000000000000..f63f940df0a8 --- /dev/null +++ b/internal/coreinternal/parseutils/doc.go @@ -0,0 +1,4 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package parseutils // import "github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal/parseutils" diff --git a/internal/coreinternal/parseutils/package_test.go b/internal/coreinternal/parseutils/package_test.go new file mode 100644 index 000000000000..20e63515f3af --- /dev/null +++ b/internal/coreinternal/parseutils/package_test.go @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package parseutils + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/internal/coreinternal/parseutils/parser.go b/internal/coreinternal/parseutils/parser.go new file mode 100644 index 000000000000..2758161ec565 --- /dev/null +++ b/internal/coreinternal/parseutils/parser.go @@ -0,0 +1,73 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package parseutils // import "github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal/parseutils" + +import ( + "fmt" + "strings" + + "go.uber.org/multierr" +) + +// SplitString will split the input on the delimiter and return the resulting slice while respecting quotes. Outer quotes are stripped. +// Use in place of `strings.Split` when quotes need to be respected. +// Requires `delimiter` not be an empty string +func SplitString(input, delimiter string) ([]string, error) { + var result []string + current := "" + delimiterLength := len(delimiter) + quoteChar := "" // "" means we are not in quotes + + for i := 0; i < len(input); i++ { + if quoteChar == "" && i+delimiterLength <= len(input) && input[i:i+delimiterLength] == delimiter { // delimiter + if current == "" { // leading || trailing delimiter; ignore + i += delimiterLength - 1 + continue + } + result = append(result, current) + current = "" + i += delimiterLength - 1 + continue + } + + if quoteChar == "" && (input[i] == '"' || input[i] == '\'') { // start of quote + quoteChar = string(input[i]) + continue + } + if string(input[i]) == quoteChar { // end of quote + quoteChar = "" + continue + } + + current += string(input[i]) + } + + if quoteChar != "" { // check for closed quotes + return nil, fmt.Errorf("never reached the end of a quoted value") + } + if current != "" { // avoid adding empty value bc of a trailing delimiter + return append(result, current), nil + } + + return result, nil +} + +// ParseKeyValuePairs will split each string in `pairs` on the `delimiter` into a key and value string that get added to a map and returned. +func ParseKeyValuePairs(pairs []string, delimiter string) (map[string]any, error) { + parsed := make(map[string]any) + var err error + for _, p := range pairs { + pair := strings.SplitN(p, delimiter, 2) + if len(pair) != 2 { + err = multierr.Append(err, fmt.Errorf("cannot split %q into 2 items, got %d item(s)", p, len(pair))) + continue + } + + key := strings.TrimSpace(pair[0]) + value := strings.TrimSpace(pair[1]) + + parsed[key] = value + } + return parsed, err +} diff --git a/internal/coreinternal/parseutils/parser_test.go b/internal/coreinternal/parseutils/parser_test.go new file mode 100644 index 000000000000..f4f8f4b14e5d --- /dev/null +++ b/internal/coreinternal/parseutils/parser_test.go @@ -0,0 +1,276 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package parseutils + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_SplitString(t *testing.T) { + testCases := []struct { + name string + input string + delimiter string + expected []string + expectedErr error + }{ + { + name: "simple", + input: "a b c", + delimiter: " ", + expected: []string{ + "a", + "b", + "c", + }, + }, + { + name: "single quotes", + input: "a 'b c d'", + delimiter: " ", + expected: []string{ + "a", + "b c d", + }, + }, + { + name: "double quotes", + input: `a " b c " d`, + delimiter: " ", + expected: []string{ + "a", + " b c ", + "d", + }, + }, + { + name: "multi-char delimiter", + input: "abc!@! def !@! g", + delimiter: "!@!", + expected: []string{ + "abc", + " def ", + " g", + }, + }, + { + name: "leading and trailing delimiters", + input: " name=ottl func=key_value hello=world ", + delimiter: " ", + expected: []string{ + "name=ottl", + "func=key_value", + "hello=world", + }, + }, + { + name: "embedded double quotes in single quoted value", + input: `ab c='this is a "co ol" value'`, + delimiter: " ", + expected: []string{ + "ab", + `c=this is a "co ol" value`, + }, + }, + { + name: "embedded double quotes end single quoted value", + input: `ab c='this is a "co ol"'`, + delimiter: " ", + expected: []string{ + "ab", + `c=this is a "co ol"`, + }, + }, + { + name: "quoted values include whitespace", + input: `name=" ottl " func=" key_ value"`, + delimiter: " ", + expected: []string{ + "name= ottl ", + "func= key_ value", + }, + }, + { + name: "delimiter longer than input", + input: "abc", + delimiter: "aaaa", + expected: []string{ + "abc", + }, + }, + { + name: "delimiter not found", + input: "a b c", + delimiter: "!", + expected: []string{ + "a b c", + }, + }, + { + name: "newlines in input", + input: `a +b +c`, + delimiter: " ", + expected: []string{ + "a\nb\nc", + }, + }, + { + name: "newline delimiter", + input: `a b c +d e f +g +h`, + delimiter: "\n", + expected: []string{ + "a b c", + "d e f", + "g ", + "h", + }, + }, + { + name: "empty input", + input: "", + delimiter: " ", + expected: nil, + }, + { + name: "equal input and delimiter", + input: "abc", + delimiter: "abc", + expected: nil, + }, + { + name: "unclosed quotes", + input: "a 'b c", + delimiter: " ", + expectedErr: fmt.Errorf("never reached the end of a quoted value"), + }, + { + name: "mismatched quotes", + input: `a 'b c' "d '`, + delimiter: " ", + expectedErr: fmt.Errorf("never reached the end of a quoted value"), + }, + { + name: "tab delimiters", + input: "a b c", + delimiter: "\t", + expected: []string{ + "a", + "b", + "c", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := SplitString(tc.input, tc.delimiter) + + if tc.expectedErr == nil { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } else { + assert.EqualError(t, err, tc.expectedErr.Error()) + assert.Nil(t, result) + } + }) + } +} + +func Test_ParseKeyValuePairs(t *testing.T) { + testCases := []struct { + name string + pairs []string + delimiter string + expected map[string]any + expectedErr error + }{ + { + name: "multiple delimiters", + pairs: []string{"a==b", "c=d=", "e=f"}, + delimiter: "=", + expected: map[string]any{ + "a": "=b", + "c": "d=", + "e": "f", + }, + }, + { + name: "no delimiter found", + pairs: []string{"ab"}, + delimiter: "=", + expectedErr: fmt.Errorf("cannot split \"ab\" into 2 items, got 1 item(s)"), + }, + { + name: "no delimiter found 2x", + pairs: []string{"ab", "cd"}, + delimiter: "=", + expectedErr: fmt.Errorf("cannot split \"ab\" into 2 items, got 1 item(s); cannot split \"cd\" into 2 items, got 1 item(s)"), + }, + { + name: "empty pairs", + pairs: []string{}, + delimiter: "=", + expected: map[string]any{}, + }, + { + name: "empty pair string", + pairs: []string{""}, + delimiter: "=", + expectedErr: fmt.Errorf("cannot split \"\" into 2 items, got 1 item(s)"), + }, + { + name: "empty delimiter", + pairs: []string{"a=b", "c=d"}, + delimiter: "", + expected: map[string]any{ + "a": "=b", + "c": "=d", + }, + }, + { + name: "empty pairs & delimiter", + pairs: []string{}, + delimiter: "", + expected: map[string]any{}, + }, + { + name: "early delimiter", + pairs: []string{"=a=b"}, + delimiter: "=", + expected: map[string]any{ + "": "a=b", + }, + }, + { + name: "weird spacing", + pairs: []string{" a= b ", " c = d "}, + delimiter: "=", + expected: map[string]any{ + "a": "b", + "c": "d", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseKeyValuePairs(tc.pairs, tc.delimiter) + + if tc.expectedErr == nil { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } else { + assert.EqualError(t, err, tc.expectedErr.Error()) + } + }) + } +} diff --git a/pkg/ottl/e2e/e2e_test.go b/pkg/ottl/e2e/e2e_test.go index 57bbedb708fe..4235d506d568 100644 --- a/pkg/ottl/e2e/e2e_test.go +++ b/pkg/ottl/e2e/e2e_test.go @@ -437,6 +437,30 @@ func Test_e2e_converters(t *testing.T) { m.PutDouble("id", 1) }, }, + { + statement: `set(attributes["test"], ParseKeyValue("k1=v1 k2=v2"))`, + want: func(tCtx ottllog.TransformContext) { + m := tCtx.GetLogRecord().Attributes().PutEmptyMap("test") + m.PutStr("k1", "v1") + m.PutStr("k2", "v2") + }, + }, + { + statement: `set(attributes["test"], ParseKeyValue("k1!v1_k2!v2", "!", "_"))`, + want: func(tCtx ottllog.TransformContext) { + m := tCtx.GetLogRecord().Attributes().PutEmptyMap("test") + m.PutStr("k1", "v1") + m.PutStr("k2", "v2") + }, + }, + { + statement: `set(attributes["test"], ParseKeyValue("k1!v1_k2!\"v2__!__v2\"", "!", "_"))`, + want: func(tCtx ottllog.TransformContext) { + m := tCtx.GetLogRecord().Attributes().PutEmptyMap("test") + m.PutStr("k1", "v1") + m.PutStr("k2", "v2__!__v2") + }, + }, { statement: `set(attributes["test"], Seconds(Duration("1m")))`, want: func(tCtx ottllog.TransformContext) { diff --git a/pkg/ottl/ottlfuncs/README.md b/pkg/ottl/ottlfuncs/README.md index f4d6923e96f9..794d1f411387 100644 --- a/pkg/ottl/ottlfuncs/README.md +++ b/pkg/ottl/ottlfuncs/README.md @@ -410,6 +410,7 @@ Available Converters: - [Nanoseconds](#nanoseconds) - [Now](#now) - [ParseJSON](#parsejson) +- [ParseKeyValue](#parsekeyvalue) - [Seconds](#seconds) - [SHA1](#sha1) - [SHA256](#sha256) @@ -840,6 +841,26 @@ Examples: - `ParseJSON(body)` +### ParseKeyValue + +`ParseKeyValue(target, Optional[delimiter], Optional[pair_delimiter])` + +The `ParseKeyValue` Converter returns a `pcommon.Map` that is a result of parsing the target string for key value pairs. + +`target` is a Getter that returns a string. If the returned string is empty, an error will be returned. `delimiter` is an optional string that is used to split the key and value in a pair, the default is `=`. `pair_delimiter` is an optional string that is used to split key value pairs, the default is a single space (` `). + +For example, the following target `"k1=v1 k2=v2 k3=v3"` will use default delimiters and be parsed into the following map: +``` +{ "k1": "v1", "k2": "v2", "k3": "v3" } +``` + +Examples: + +- `ParseKeyValue("k1=v1 k2=v2 k3=v3")` +- `ParseKeyValue("k1!v1_k2!v2_k3!v3", "!", "_")` +- `ParseKeyValue(attributes["pairs"])` + + ### Seconds `Seconds(value)` diff --git a/pkg/ottl/ottlfuncs/func_parse_key_value.go b/pkg/ottl/ottlfuncs/func_parse_key_value.go new file mode 100644 index 000000000000..1b896656ebe2 --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_parse_key_value.go @@ -0,0 +1,81 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ottlfuncs // import "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl/ottlfuncs" + +import ( + "context" + "fmt" + + "go.opentelemetry.io/collector/pdata/pcommon" + + "github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal/parseutils" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +type ParseKeyValueArguments[K any] struct { + Target ottl.StringGetter[K] + Delimiter ottl.Optional[string] + PairDelimiter ottl.Optional[string] +} + +func NewParseKeyValueFactory[K any]() ottl.Factory[K] { + return ottl.NewFactory("ParseKeyValue", &ParseKeyValueArguments[K]{}, createParseKeyValueFunction[K]) +} + +func createParseKeyValueFunction[K any](_ ottl.FunctionContext, oArgs ottl.Arguments) (ottl.ExprFunc[K], error) { + args, ok := oArgs.(*ParseKeyValueArguments[K]) + + if !ok { + return nil, fmt.Errorf("ParseKeyValueFactory args must be of type *ParseKeyValueArguments[K]") + } + + return parseKeyValue[K](args.Target, args.Delimiter, args.PairDelimiter) +} + +func parseKeyValue[K any](target ottl.StringGetter[K], d ottl.Optional[string], p ottl.Optional[string]) (ottl.ExprFunc[K], error) { + delimiter := "=" + if !d.IsEmpty() { + if d.Get() == "" { + return nil, fmt.Errorf("delimiter cannot be set to an empty string") + } + delimiter = d.Get() + } + + pairDelimiter := " " + if !p.IsEmpty() { + if p.Get() == "" { + return nil, fmt.Errorf("pair delimiter cannot be set to an empty string") + } + pairDelimiter = p.Get() + } + + if pairDelimiter == delimiter { + return nil, fmt.Errorf("pair delimiter %q cannot be equal to delimiter %q", pairDelimiter, delimiter) + } + + return func(ctx context.Context, tCtx K) (any, error) { + source, err := target.Get(ctx, tCtx) + if err != nil { + return nil, err + } + + if source == "" { + return nil, fmt.Errorf("cannot parse from empty target") + } + + pairs, err := parseutils.SplitString(source, pairDelimiter) + if err != nil { + return nil, fmt.Errorf("splitting source %q into pairs failed: %w", source, err) + } + + parsed, err := parseutils.ParseKeyValuePairs(pairs, delimiter) + if err != nil { + return nil, fmt.Errorf("failed to split pairs into key-values: %w", err) + } + + result := pcommon.NewMap() + err = result.FromRaw(parsed) + return result, err + }, nil +} diff --git a/pkg/ottl/ottlfuncs/func_parse_key_value_test.go b/pkg/ottl/ottlfuncs/func_parse_key_value_test.go new file mode 100644 index 000000000000..340c29b8300a --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_parse_key_value_test.go @@ -0,0 +1,376 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ottlfuncs + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/pdata/pcommon" + + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +func Test_parseKeyValue(t *testing.T) { + tests := []struct { + name string + target ottl.StringGetter[any] + delimiter ottl.Optional[string] + pairDelimiter ottl.Optional[string] + expected map[string]any + }{ + { + name: "simple", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "name=ottl func=key_value", nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "name": "ottl", + "func": "key_value", + }, + }, + { + name: "large", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `name=ottl age=1 job="software engineering" location="grand rapids michigan" src="10.3.3.76" dst=172.217.0.10 protocol=udp sport=57112 port=443 translated_src_ip=96.63.176.3 translated_port=57112`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "age": "1", + "port": "443", + "dst": "172.217.0.10", + "job": "software engineering", + "location": "grand rapids michigan", + "name": "ottl", + "protocol": "udp", + "sport": "57112", + "src": "10.3.3.76", + "translated_port": "57112", + "translated_src_ip": "96.63.176.3", + }, + }, + { + name: "embedded double quotes in single quoted value", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `a=b c='this is a "co ol" value'`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "a": "b", + "c": "this is a \"co ol\" value", + }, + }, + { + name: "double quotes", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `requestClientApplication="Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0"`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "requestClientApplication": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0", + }, + }, + { + name: "single quotes", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "requestClientApplication='Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0'", nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "requestClientApplication": "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.0", + }, + }, + { + name: "double quotes strip leading & trailing spaces", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `name=" ottl " func=" key_ value"`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "name": "ottl", + "func": "key_ value", + }, + }, + { + name: "! delimiter && whitespace pair delimiter", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return " name!ottl func!key_value hello!world ", nil + }, + }, + delimiter: ottl.NewTestingOptional[string]("!"), + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "name": "ottl", + "func": "key_value", + "hello": "world", + }, + }, + { + name: "!! delimiter && whitespace pair delimiter with newlines", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return ` +name!!ottl +func!!key_value hello!!world `, nil + }, + }, + delimiter: ottl.NewTestingOptional[string]("!!"), + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "name": "ottl", + "func": "key_value", + "hello": "world", + }, + }, + { + name: "!! delimiter && newline pair delimiter", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `name!!ottl +func!! key_value another!!pair +hello!!world `, nil + }, + }, + delimiter: ottl.NewTestingOptional[string]("!!"), + pairDelimiter: ottl.NewTestingOptional[string]("\n"), + expected: map[string]any{ + "name": "ottl", + "func": "key_value another!!pair", + "hello": "world", + }, + }, + { + name: "quoted value contains delimiter and pair delimiter", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `name="ottl="_func="=key_value"`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.NewTestingOptional("_"), + expected: map[string]any{ + "name": "ottl=", + "func": "=key_value", + }, + }, + { + name: "complicated delimiters", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `k1@*v1_!_k2@**v2_!__k3@@*v3__`, nil + }, + }, + delimiter: ottl.NewTestingOptional("@*"), + pairDelimiter: ottl.NewTestingOptional("_!_"), + expected: map[string]any{ + "k1": "v1", + "k2": "*v2", + "_k3@": "v3__", + }, + }, + { + name: "leading and trailing pair delimiter", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return " k1=v1 k2==v2 k3=v3= ", nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "k1": "v1", + "k2": "=v2", + "k3": "v3=", + }, + }, + { + name: "embedded double quotes end single quoted value", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `a=b c='this is a "co ol"'`, nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "a": "b", + "c": "this is a \"co ol\"", + }, + }, + { + name: "more quotes", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "a=b c=d'='", nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.Optional[string]{}, + expected: map[string]any{ + "a": "b", + "c": "d=", + }, + }, + + { + name: "long pair delimiter", + target: ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "a=b c=d", nil + }, + }, + delimiter: ottl.Optional[string]{}, + pairDelimiter: ottl.NewTestingOptional("aaaaaaaaaaaaaaaa"), + expected: map[string]any{ + "a": "b c=d", // occurs because `SplitString()` returns original string and `strings.SplitN` with N=2 will split on just the first instance of delimiter("=") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exprFunc, err := parseKeyValue[any](tt.target, tt.delimiter, tt.pairDelimiter) + assert.NoError(t, err) + + result, err := exprFunc(context.Background(), nil) + assert.NoError(t, err) + + actual, ok := result.(pcommon.Map) + assert.True(t, ok) + + expected := pcommon.NewMap() + assert.NoError(t, expected.FromRaw(tt.expected)) + + assert.Equal(t, expected.Len(), actual.Len()) + expected.Range(func(k string, v pcommon.Value) bool { + ev, _ := expected.Get(k) + av, ok := actual.Get(k) + assert.True(t, ok) + assert.Equal(t, ev, av) + return true + }) + }) + } +} + +func Test_parseKeyValue_equal_delimiters(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "", nil + }, + } + delimiter := ottl.NewTestingOptional[string]("=") + pairDelimiter := ottl.NewTestingOptional[string]("=") + _, err := parseKeyValue[any](target, delimiter, pairDelimiter) + assert.Error(t, err) + + delimiter = ottl.NewTestingOptional[string](" ") + _, err = parseKeyValue[any](target, delimiter, ottl.Optional[string]{}) + assert.Error(t, err) +} + +func Test_parseKeyValue_bad_target(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return 1, nil + }, + } + delimiter := ottl.NewTestingOptional[string]("=") + pairDelimiter := ottl.NewTestingOptional[string]("!") + exprFunc, err := parseKeyValue[any](target, delimiter, pairDelimiter) + assert.NoError(t, err) + _, err = exprFunc(context.Background(), nil) + assert.Error(t, err) +} + +func Test_parseKeyValue_empty_target(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "", nil + }, + } + delimiter := ottl.NewTestingOptional[string]("=") + pairDelimiter := ottl.NewTestingOptional[string]("!") + exprFunc, err := parseKeyValue[any](target, delimiter, pairDelimiter) + assert.NoError(t, err) + _, err = exprFunc(context.Background(), nil) + assert.Error(t, err) +} + +func Test_parseKeyValue_bad_split(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "name=ottl!hello_world", nil + }, + } + delimiter := ottl.NewTestingOptional[string]("=") + pairDelimiter := ottl.NewTestingOptional[string]("!") + exprFunc, err := parseKeyValue[any](target, delimiter, pairDelimiter) + assert.NoError(t, err) + _, err = exprFunc(context.Background(), nil) + assert.ErrorContains(t, err, "failed to split pairs into key-values: cannot split \"hello_world\" into 2 items, got 1 item(s)") +} + +func Test_parseKeyValue_mismatch_quotes(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return `k1=v1 k2='v2"`, nil + }, + } + exprFunc, err := parseKeyValue[any](target, ottl.Optional[string]{}, ottl.Optional[string]{}) + assert.NoError(t, err) + _, err = exprFunc(context.Background(), nil) + assert.Error(t, err) +} + +func Test_parseKeyValue_bad_delimiter(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "a=b c=d", nil + }, + } + + // covers too long of a delimiter && delimiter not found + delimiter := ottl.NewTestingOptional[string]("=============") + exprFunc, err := parseKeyValue[any](target, delimiter, ottl.Optional[string]{}) + assert.NoError(t, err) + _, err = exprFunc(context.Background(), nil) + assert.ErrorContains(t, err, "failed to split pairs into key-values: cannot split \"a=b\" into 2 items, got 1 item(s)") +} + +func Test_parseKeyValue_empty_delimiters(t *testing.T) { + target := ottl.StandardStringGetter[any]{ + Getter: func(ctx context.Context, tCtx any) (any, error) { + return "a=b c=d", nil + }, + } + delimiter := ottl.NewTestingOptional[string]("") + + _, err := parseKeyValue[any](target, delimiter, ottl.Optional[string]{}) + assert.ErrorContains(t, err, "delimiter cannot be set to an empty string") + + _, err = parseKeyValue[any](target, ottl.Optional[string]{}, delimiter) + assert.ErrorContains(t, err, "pair delimiter cannot be set to an empty string") +} diff --git a/pkg/ottl/ottlfuncs/functions.go b/pkg/ottl/ottlfuncs/functions.go index 3e498549a58c..657b88280367 100644 --- a/pkg/ottl/ottlfuncs/functions.go +++ b/pkg/ottl/ottlfuncs/functions.go @@ -57,6 +57,7 @@ func converters[K any]() []ottl.Factory[K] { NewNanosecondsFactory[K](), NewNowFactory[K](), NewParseJSONFactory[K](), + NewParseKeyValueFactory[K](), NewSecondsFactory[K](), NewSHA1Factory[K](), NewSHA256Factory[K](),