Skip to content
Merged
Show file tree
Hide file tree
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
9 changes: 9 additions & 0 deletions .chloggen/ottl-add-coalesce-converter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
change_type: "enhancement"
component: "pkg/ottl"
note: "Add Coalesce converter that returns the first non-nil value from a list of arguments."
issues: [46847]
subtext: |
The Coalesce converter accepts a list of values and returns the first one that is not nil.
This simplifies common patterns where a canonical attribute must be resolved from multiple possible sources.
Example: `set(attributes["user"], Coalesce([attributes["user.id"], attributes["enduser.id"], "unknown"]))`
change_logs: ["user"]
22 changes: 20 additions & 2 deletions pkg/ottl/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,24 @@ func Test_e2e_converters(t *testing.T) {
tCtx.GetLogRecord().Attributes().PutStr("decoded_base64", "pass")
},
},
{
statement: `set(attributes["test"], Coalesce([attributes["http.method"], attributes["http.path"], "fallback"]))`,
want: func(tCtx *ottllog.TransformContext) {
tCtx.GetLogRecord().Attributes().PutStr("test", "get")
},
},
{
statement: `set(attributes["test"], Coalesce([attributes["nonexistent"], attributes["http.method"], "fallback"]))`,
want: func(tCtx *ottllog.TransformContext) {
tCtx.GetLogRecord().Attributes().PutStr("test", "get")
},
},
{
statement: `set(attributes["test"], Coalesce([attributes["nonexistent"], attributes["also.missing"], "fallback"]))`,
want: func(tCtx *ottllog.TransformContext) {
tCtx.GetLogRecord().Attributes().PutStr("test", "fallback")
},
},
{
statement: `set(attributes["test"], Concat(["A","B"], ":"))`,
want: func(tCtx *ottllog.TransformContext) {
Expand Down Expand Up @@ -1459,8 +1477,8 @@ func Test_e2e_converters(t *testing.T) {
},
{
statement: `set(
attributes["test"],
ParseSeverity(severity_number,
attributes["test"],
ParseSeverity(severity_number,
{
"error":[
{"equals": ["err"]},
Expand Down
18 changes: 18 additions & 0 deletions pkg/ottl/ottlfuncs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ Available Converters:
- [Base64Encode](#base64encode)
- [Bool](#bool)
- [Decode](#decode)
- [Coalesce](#coalesce)
- [CommunityID](#communityid)
- [Concat](#concat)
- [ContainsValue](#containsvalue)
Expand Down Expand Up @@ -650,6 +651,23 @@ Examples:

- `Decode(resource.attributes["encoded field"], "us-ascii")`

### Coalesce

`Coalesce(values[])`

The `Coalesce` Converter returns the first non-nil value from a list of values.

`values` is a list of values. Each can be a path expression, a literal, or a nested converter call. At least one value is required.

If all values are `nil`, the Converter returns `nil`.

Examples:

- `Coalesce([attributes["user.id"], attributes["enduser.id"], "unknown"])`


- `Coalesce([resource.attributes["deployment.environment.name"], resource.attributes["deployment.environment"]])`

### CommunityID

`CommunityID(sourceIP, sourcePort, destinationIP, destinationPort, Optional[protocol], Optional[seed])`
Expand Down
47 changes: 47 additions & 0 deletions pkg/ottl/ottlfuncs/func_coalesce.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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"

"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl"
)

type CoalesceArguments[K any] struct {
Values []ottl.Getter[K]
}

func NewCoalesceFactory[K any]() ottl.Factory[K] {
return ottl.NewFactory("Coalesce", &CoalesceArguments[K]{}, createCoalesceFunction[K])
}

func createCoalesceFunction[K any](_ ottl.FunctionContext, oArgs ottl.Arguments) (ottl.ExprFunc[K], error) {
args, ok := oArgs.(*CoalesceArguments[K])
if !ok {
return nil, errors.New("CoalesceFactory args must be of type *CoalesceArguments[K]")
}

if len(args.Values) == 0 {
return nil, errors.New("Coalesce requires at least one argument")
}

return coalesce(args.Values), nil
}

func coalesce[K any](values []ottl.Getter[K]) ottl.ExprFunc[K] {
return func(ctx context.Context, tCtx K) (any, error) {
for _, val := range values {
v, err := val.Get(ctx, tCtx)
if err != nil {
return nil, err
}
if v != nil {
return v, nil
}
}
return nil, nil
}
}
197 changes: 197 additions & 0 deletions pkg/ottl/ottlfuncs/func_coalesce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package ottlfuncs

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl"
)

func Test_coalesce(t *testing.T) {
tests := []struct {
name string
values []ottl.Getter[any]
expected any
}{
{
name: "first value non-nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "first", nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "second", nil
}},
},
expected: "first",
},
{
name: "first nil second non-nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "second", nil
}},
},
expected: "second",
},
{
name: "first two nil third non-nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "third", nil
}},
},
expected: "third",
},
{
name: "all nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
},
expected: nil,
},
{
name: "single value non-nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "only", nil
}},
},
expected: "only",
},
{
name: "single value nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
},
expected: nil,
},
{
name: "returns int64 value",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return int64(42), nil
}},
},
expected: int64(42),
},
{
name: "returns bool value",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return true, nil
}},
},
expected: true,
},
{
name: "returns float64 value",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return 3.14, nil
}},
},
expected: 3.14,
},
{
name: "does not evaluate past first non-nil",
values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "found", nil
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, errors.New("should not be reached")
}},
},
expected: "found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exprFunc := coalesce[any](tt.values)
result, err := exprFunc(t.Context(), nil)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

func Test_coalesce_error(t *testing.T) {
values := []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return nil, errors.New("getter error")
}},
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "value", nil
}},
}

exprFunc := coalesce[any](values)
result, err := exprFunc(t.Context(), nil)
assert.Nil(t, result)
assert.EqualError(t, err, "getter error")
}

func Test_createCoalesceFunction(t *testing.T) {
factory := NewCoalesceFactory[any]()
fCtx := ottl.FunctionContext{}

t.Run("valid args", func(t *testing.T) {
args := &CoalesceArguments[any]{
Values: []ottl.Getter[any]{
&ottl.StandardGetSetter[any]{Getter: func(context.Context, any) (any, error) {
return "test", nil
}},
},
}
fn, err := factory.CreateFunction(fCtx, args)
require.NoError(t, err)
require.NotNil(t, fn)
})

t.Run("empty values", func(t *testing.T) {
args := &CoalesceArguments[any]{
Values: []ottl.Getter[any]{},
}
_, err := factory.CreateFunction(fCtx, args)
assert.EqualError(t, err, "Coalesce requires at least one argument")
})

t.Run("wrong args type", func(t *testing.T) {
args := &ConcatArguments[any]{}
_, err := factory.CreateFunction(fCtx, args)
assert.EqualError(t, err, "CoalesceFactory args must be of type *CoalesceArguments[K]")
})
}
1 change: 1 addition & 0 deletions pkg/ottl/ottlfuncs/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func converters[K any]() []ottl.Factory[K] {
NewBase64EncodeFactory[K](),
NewBoolFactory[K](),
NewDecodeFactory[K](),
NewCoalesceFactory[K](),
NewCommunityIDFactory[K](),
NewConcatFactory[K](),
NewContainsValueFactory[K](),
Expand Down
Loading