diff --git a/.chloggen/parse-severity-function.yaml b/.chloggen/parse-severity-function.yaml new file mode 100644 index 0000000000000..a2d3aa9352744 --- /dev/null +++ b/.chloggen/parse-severity-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 `ParseSeverity` function to define mappings for log severity levels. + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [35778] + +# (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/pkg/ottl/e2e/e2e_test.go b/pkg/ottl/e2e/e2e_test.go index 3a21e1b45ba4f..15727ca99b7a3 100644 --- a/pkg/ottl/e2e/e2e_test.go +++ b/pkg/ottl/e2e/e2e_test.go @@ -1224,6 +1224,24 @@ func Test_e2e_converters(t *testing.T) { tCtx.GetLogRecord().Attributes().PutInt("test", 2) }, }, + { + statement: `set( + attributes["test"], + ParseSeverity(severity_number, + { + "error":[ + {"equals": ["err"]}, + {"range": { "min": 3, "max": 4 }} + ], + "info":[ + {"range": { "min": 1, "max": 2 }} + ], + } + ))`, + want: func(tCtx ottllog.TransformContext) { + tCtx.GetLogRecord().Attributes().PutStr("test", "info") + }, + }, { statement: `set(attributes["list"], Sort(Keys({"foo": "bar", "baz": "foo"})))`, want: func(tCtx ottllog.TransformContext) { diff --git a/pkg/ottl/ottlfuncs/README.md b/pkg/ottl/ottlfuncs/README.md index 9918235c911e8..751df50cc6361 100644 --- a/pkg/ottl/ottlfuncs/README.md +++ b/pkg/ottl/ottlfuncs/README.md @@ -503,6 +503,7 @@ Available Converters: - [ParseInt](#parseint) - [ParseJSON](#parsejson) - [ParseKeyValue](#parsekeyvalue) +- [ParseSeverity](#parseseverity) - [ParseSimplifiedXML](#parsesimplifiedxml) - [ParseXML](#parsexml) - [ProfileID](#profileid) @@ -1623,6 +1624,34 @@ Examples: - `ParseKeyValue("k1!v1_k2!v2_k3!v3", "!", "_")` - `ParseKeyValue(log.attributes["pairs"])` +### ParseSeverity + +`ParseSeverity(target, severityMapping)` + +The `ParseSeverity` converter returns a `string` that represents one of the log levels defined by `severityMapping`. + +`target` is a Getter that returns a string or an integer. +`severityMapping` is a map containing the log levels, and a list of values they are mapped from. These values can be either +strings, or map items containing a numeric range, defined by a `min` and `max` key (inclusive bounds), for the given log level. +A value will be mapped to the given log level if any of these conditions are true. +For example, the following mapping will map to the `info` level, if the `target` is either a string with the value `inf`, +or an integer in the range `[200,299]`: + +`{"info":[{"equals": ["inf"]}, {"range":{"min":200, "max":299}}]}` + +There is also support for expressing certain status code ranges via a placeholder string. The supported placeholders are the following: + +- `"2xx"`: This string matches integer values between `[200,299]` +- `"3xx"`: This string matches integer values between `[300,399]` +- `"4xx"`: This string matches integer values between `[400,499]` +- `"5xx"`: This string matches integer values between `[500,599]` + +Examples: + +- `ParseSeverity(attributes["log-level"] {"info":[{"equals": ["inf"]}, {"range":{"min":200, "max":299}}]})` +- `ParseSeverity(attributes["log-level"] {"info":[{"range":"2xx""}]})` +- `ParseSeverity(severity_number {"info":[{"equals": ["inf"]}, {"range":{"min":200, "max":299}}], "error":[{"range":{"min":400, "max":499}}]})` + ### ParseSimplifiedXML `ParseSimplifiedXML(target)` diff --git a/pkg/ottl/ottlfuncs/func_parse_severity.go b/pkg/ottl/ottlfuncs/func_parse_severity.go new file mode 100644 index 0000000000000..767f4fcc764be --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_parse_severity.go @@ -0,0 +1,200 @@ +// 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" + "errors" + "fmt" + + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +const ( + // http2xx is a special key that is represents a range from 200 to 299 + http2xx = "2xx" + + // http3xx is a special key that is represents a range from 300 to 399 + http3xx = "3xx" + + // http4xx is a special key that is represents a range from 400 to 499 + http4xx = "4xx" + + // http5xx is a special key that is represents a range from 500 to 599 + http5xx = "5xx" + + minKey = "min" + maxKey = "max" + + rangeKey = "range" + equalsKey = "equals" +) + +type ParseSeverityArguments[K any] struct { + Target ottl.Getter[K] + Mapping ottl.PMapGetter[K] +} + +func NewParseSeverityFactory[K any]() ottl.Factory[K] { + return ottl.NewFactory("ParseSeverity", &ParseSeverityArguments[K]{}, createParseSeverityFunction[K]) +} + +func createParseSeverityFunction[K any](_ ottl.FunctionContext, oArgs ottl.Arguments) (ottl.ExprFunc[K], error) { + args, ok := oArgs.(*ParseSeverityArguments[K]) + + if !ok { + return nil, errors.New("ParseSeverityFactory args must be of type *ParseSeverityArguments[K") + } + + return parseSeverity[K](args.Target, args.Mapping), nil +} + +func parseSeverity[K any](target ottl.Getter[K], mapping ottl.PMapGetter[K]) ottl.ExprFunc[K] { + return func(ctx context.Context, tCtx K) (any, error) { + severityMap, err := mapping.Get(ctx, tCtx) + if err != nil { + return nil, fmt.Errorf("cannot get severity mapping: %w", err) + } + + value, err := target.Get(ctx, tCtx) + if err != nil { + return nil, fmt.Errorf("could not get log level: %w", err) + } + + logLevel, err := evaluateSeverity(value, severityMap.AsRaw()) + if err != nil { + return nil, fmt.Errorf("could not map log level: %w", err) + } + + return logLevel, nil + } +} + +func evaluateSeverity(value any, severities map[string]any) (string, error) { + for level, criteria := range severities { + criteriaList, ok := criteria.([]any) + if !ok { + return "", errors.New("criteria for mapping log level must be []any") + } + match, err := evaluateSeverityMapping(value, criteriaList) + if err != nil { + return "", fmt.Errorf("could not evaluate log level of value '%v': %w", value, err) + } + if match { + return level, nil + } + } + return "", fmt.Errorf("no matching log level found for value '%v'", value) +} + +func evaluateSeverityMapping(value any, criteria []any) (bool, error) { + switch v := value.(type) { + case string: + return evaluateSeverityStringMapping(v, criteria), nil + case int64: + return evaluateSeverityNumberMapping(v, criteria) + default: + return false, fmt.Errorf("log level must be either string or int64, but got %T", v) + } +} + +func evaluateSeverityNumberMapping(value int64, criteria []any) (bool, error) { + for _, crit := range criteria { + criteriaItem, ok := crit.(map[string]any) + if !ok { + continue + } + + // right now, we only have a "range" criteria for numeric log levels, so we specifically check for this here + rangeMapObj, ok := criteriaItem[rangeKey] + if !ok { + continue + } + + // if we have a numeric severity number, we need to match with number ranges + rangeMap, ok := rangeMapObj.(map[string]any) + if !ok { + rangeMap, ok = parseValueRangePlaceholder(rangeMapObj) + if !ok { + continue + } + } + rangeMin, gotMin := rangeMap[minKey] + rangeMax, gotMax := rangeMap[maxKey] + if !gotMin || !gotMax { + return false, errors.New("range criteria must contain min and max values") + } + rangeMinInt, ok := rangeMin.(int64) + if !ok { + return false, fmt.Errorf("min must be int64, but got %T", rangeMin) + } + rangeMaxInt, ok := rangeMax.(int64) + if !ok { + return false, fmt.Errorf("max must be int64, but got %T", rangeMax) + } + + if rangeMinInt <= value && rangeMaxInt >= value { + return true, nil + } + } + return false, nil +} + +func parseValueRangePlaceholder(crit any) (map[string]any, bool) { + placeholder, ok := crit.(string) + if !ok { + return nil, false + } + + switch placeholder { + case http2xx: + return map[string]any{ + "min": int64(200), + "max": int64(299), + }, true + case http3xx: + return map[string]any{ + "min": int64(300), + "max": int64(399), + }, true + case http4xx: + return map[string]any{ + "min": int64(400), + "max": int64(499), + }, true + case http5xx: + return map[string]any{ + "min": int64(500), + "max": int64(599), + }, true + default: + return nil, false + } +} + +func evaluateSeverityStringMapping(value string, criteria []any) bool { + for _, crit := range criteria { + criteriaItem, ok := crit.(map[string]any) + if !ok { + return false + } + criteriaEquals, ok := criteriaItem[equalsKey] + if !ok { + return false + } + + equalsObjs, ok := criteriaEquals.([]any) + if !ok { + return false + } + for _, equals := range equalsObjs { + if equalsStr, ok := equals.(string); ok { + if equalsStr == value { + return true + } + } + } + } + return false +} diff --git a/pkg/ottl/ottlfuncs/func_parse_severity_test.go b/pkg/ottl/ottlfuncs/func_parse_severity_test.go new file mode 100644 index 0000000000000..524e2ae937486 --- /dev/null +++ b/pkg/ottl/ottlfuncs/func_parse_severity_test.go @@ -0,0 +1,422 @@ +// 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" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/pdata/pcommon" + + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl" +) + +func Test_parseSeverity(t *testing.T) { + tests := []struct { + name string + target ottl.Getter[any] + mapping ottl.PMapGetter[any] + expected string + expectErrorMsg string + }{ + { + name: "map from status code - error level", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return getTestSeverityMapping(), nil + }, + }, + expected: "error", + }, + { + name: "map from status code - debug level", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(100), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return getTestSeverityMapping(), nil + }, + }, + expected: "debug", + }, + { + name: "map from status code based on value range", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(200), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + mapping := m.PutEmptySlice("info") + mapping.AppendEmpty().SetEmptyMap().PutStr("range", "2xx") + return m, nil + }, + }, + expected: "info", + }, + { + name: "map from log level string", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return "inf", nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return getTestSeverityMapping(), nil + }, + }, + expected: "info", + }, + { + name: "map from code that matches http status range", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(200), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return getTestSeverityMapping(), nil + }, + }, + expected: "info", + }, + { + name: "map from log level string, multiple criteria of mixed types defined", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return "inf", nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s1 := m.PutEmptySlice("error") + rangeMap := s1.AppendEmpty().SetEmptyMap() + rangeMap.PutInt("min", 400) + rangeMap.PutInt("max", 599) + + s2 := m.PutEmptySlice("info") + equalsSlice := s2.AppendEmpty().SetEmptyMap().PutEmptySlice("equals") + equalsSlice.AppendEmpty().SetStr("info") + equalsSlice.AppendEmpty().SetStr("inf") + + return m, nil + }, + }, + expected: "info", + }, + { + name: "no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return "foo", nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("info") + s.AppendEmpty().SetStr("info") + s.AppendEmpty().SetStr("inf") + + return m, nil + }, + }, + expectErrorMsg: "could not map log level: no matching log level found for value 'foo'", + }, + { + name: "unexpected type in criteria", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + m.PutStr("error", "invalid") + return m, nil + }, + }, + expectErrorMsg: "could not map log level: criteria for mapping log level must be []any", + }, + { + name: "unexpected type in range criteria (min), no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("error") + rangeMap := s.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutStr("min", "foo") + rangeMap.PutInt("max", 599) + + return m, nil + }, + }, + expectErrorMsg: "could not evaluate log level of value '400': min must be int64, but got string", + }, + { + name: "unexpected type in target, no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return map[string]any{"foo": "bar"}, nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("warn") + rangeMap := s.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutInt("min", 400) + rangeMap.PutInt("max", 499) + + return m, nil + }, + }, + expectErrorMsg: "log level must be either string or int64, but got map[string]interface {}", + }, + { + name: "error in acquiring target, no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return nil, errors.New("oops") + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("warn") + rangeMap := s.AppendEmpty().SetEmptyMap() + rangeMap.PutInt("min", 400) + rangeMap.PutInt("max", 499) + + return m, nil + }, + }, + expectErrorMsg: "could not get log level: oops", + }, + { + name: "unexpected type in range criteria (max), no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("error") + rangeMap := s.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutInt("min", 400) + rangeMap.PutStr("max", "foo") + + return m, nil + }, + }, + expectErrorMsg: "could not evaluate log level of value '400': max must be int64, but got string", + }, + { + name: "missing min in range, no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("error") + rangeMap := s.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutInt("max", 599) + + return m, nil + }, + }, + expectErrorMsg: "could not map log level: could not evaluate log level of value '400': range criteria must contain min and max values", + }, + { + name: "missing max in range, no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + m := pcommon.NewMap() + s := m.PutEmptySlice("error") + rangeMap := s.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutInt("min", 400) + + return m, nil + }, + }, + expectErrorMsg: "could not map log level: could not evaluate log level of value '400': range criteria must contain min and max values", + }, + { + name: "incorrect format of severity mapping, no match", + target: ottl.StandardGetSetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return int64(400), nil + }, + }, + mapping: ottl.StandardPMapGetter[any]{ + Getter: func(_ context.Context, _ any) (any, error) { + return "invalid", nil + }, + }, + expectErrorMsg: "cannot get severity mapping: expected pcommon.Map but got string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exprFunc := parseSeverity[any](tt.target, tt.mapping) + + result, err := exprFunc(context.Background(), nil) + if tt.expectErrorMsg != "" { + assert.ErrorContains(t, err, tt.expectErrorMsg) + return + } + + require.NoError(t, err) + + resultString, ok := result.(string) + require.True(t, ok) + + assert.Equal(t, tt.expected, resultString) + }) + } +} + +func getTestSeverityMapping() pcommon.Map { + m := pcommon.NewMap() + errorMapping := m.PutEmptySlice("error") + rangeMap := errorMapping.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap.PutInt("min", 400) + rangeMap.PutInt("max", 499) + + debugMapping := m.PutEmptySlice("debug") + rangeMap2 := debugMapping.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap2.PutInt("min", 100) + rangeMap2.PutInt("max", 199) + + infoMapping := m.PutEmptySlice("info") + infoEquals := infoMapping.AppendEmpty().SetEmptyMap().PutEmptySlice("equals") + infoEquals.AppendEmpty().SetStr("inf") + infoEquals.AppendEmpty().SetStr("info") + infoMapping.AppendEmpty().SetEmptyMap().PutStr("range", http2xx) + + warnMapping := m.PutEmptySlice("warn") + rangeMap4 := warnMapping.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap4.PutInt("min", 300) + rangeMap4.PutInt("max", 399) + + fatalMapping := m.PutEmptySlice("fatal") + rangeMap5 := fatalMapping.AppendEmpty().SetEmptyMap().PutEmptyMap("range") + rangeMap5.PutInt("min", 500) + rangeMap5.PutInt("max", 599) + + return m +} + +func Test_parseValueRangePlaceholder(t *testing.T) { + type args struct { + crit any + } + tests := []struct { + name string + args args + wantMapping map[string]any + wantOk bool + }{ + { + name: "2xx", + args: args{ + crit: http2xx, + }, + wantMapping: map[string]any{ + "min": int64(200), + "max": int64(299), + }, + wantOk: true, + }, + { + name: "3xx", + args: args{ + crit: http3xx, + }, + wantMapping: map[string]any{ + "min": int64(300), + "max": int64(399), + }, + wantOk: true, + }, + { + name: "4xx", + args: args{ + crit: http4xx, + }, + wantMapping: map[string]any{ + "min": int64(400), + "max": int64(499), + }, + wantOk: true, + }, + { + name: "5xx", + args: args{ + crit: http5xx, + }, + wantMapping: map[string]any{ + "min": int64(500), + "max": int64(599), + }, + wantOk: true, + }, + { + name: "unknown", + args: args{ + crit: "unknown", + }, + wantMapping: nil, + wantOk: false, + }, + { + name: "not a string", + args: args{ + crit: 1, + }, + wantMapping: nil, + wantOk: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := parseValueRangePlaceholder(tt.args.crit) + assert.Equalf(t, tt.wantMapping, got, "parseValueRangePlaceholder(%v)", tt.args.crit) + assert.Equalf(t, tt.wantOk, got1, "parseValueRangePlaceholder(%v)", tt.args.crit) + }) + } +} diff --git a/pkg/ottl/ottlfuncs/functions.go b/pkg/ottl/ottlfuncs/functions.go index 4f316e97ec442..fccec664ed2c6 100644 --- a/pkg/ottl/ottlfuncs/functions.go +++ b/pkg/ottl/ottlfuncs/functions.go @@ -119,6 +119,7 @@ func converters[K any]() []ottl.Factory[K] { NewYearFactory[K](), NewHexFactory[K](), NewSliceToMapFactory[K](), + NewParseSeverityFactory[K](), NewProfileIDFactory[K](), NewParseIntFactory[K](), NewKeysFactory[K](),