Repository: open-telemetry/opentelemetry-collector (Go)

Issue:
Behaviour of `ToStringMap` Not Matching Confmap Decoding Workflow  
This question is related to https://github.com/open-telemetry/opentelemetry-collector/pull/11734 which is working towards https://github.com/open-telemetry/opentelemetry-collector/pull/10260 and #10266. The PR I have open enables the decoding of `nil` values in `mapstructure` so that we can use an Optional type with a default value (see https://github.com/open-telemetry/opentelemetry-collector/pull/10260#issuecomment-2241363814 for more context). While enabling the decoding of nil values, I came across an issue where we were unable to differentiate between nil and empty slices. The reason for that is because we always initialize a nil slice and do not populate it if it is empty (see https://github.com/open-telemetry/opentelemetry-collector/blob/main/confmap/confmap.go#L135). In order to get around this, I added this patch in the PR above. 
```go
		if m == nil {
			return newSlice
		}
		newSlice = []any{}
```
This allows us to differentiate between a nil slice and an empty slice which we need in the `zeroSliceHookFunc`. However, that patch causes [this](https://github.com/open-telemetry/opentelemetry-collector/blob/main/confmap/confmaptest/configtest_test.go#L33-L38) unit test to fail because there's an expectation here that an empty slice gets rendered as nil when calling `ToStringMap`. I wanted to ask if it was valid to change this expectation so that it matches the decoding workflow we have in which an empty slice is preserved. cc @yurishkuro 


Discussion:
I would be okay changing this (I consider this an implementation detail). I believe this test was added by @songy23 because a change broke some tests on the Datadog Agent, but so long as it is clearly listed in the changelog and it does not break existing use cases (Datadog or otherwise) I am okay with it.
@mx-psi Thanks for getting back to me! Would you prefer I land the patch in a dedicated PR or is it fine for it to go in https://github.com/open-telemetry/opentelemetry-collector/pull/11734?
I would prefer a dedicated PR for this :) 
@mx-psi I've got a PR up at https://github.com/open-telemetry/opentelemetry-collector/pull/11755
Addressed in #11882 and #12872
@yurishkuro I am trying to do this again, this time hopefully without breaking anything. See #12872



Patch:
diff --git a/.chloggen/mx-psi_nil-empty-slices.yaml b/.chloggen/mx-psi_nil-empty-slices.yaml
new file mode 100644
index 00000000000..efb700b0904
--- /dev/null
+++ b/.chloggen/mx-psi_nil-empty-slices.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: bug_fix
+
+# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
+component: confmap
+
+# A brief description of the change.  Surround your text with quotes ("") if it needs to start with a backtick (`).
+note: Maintain nil values when marshaling or unmarshaling nil slices
+
+# One or more tracking issues or pull requests related to the change
+issues: [11882]
+
+# (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: |
+  Previously, nil slices were converted to empty lists, which are semantically different
+  than a nil slice. This change makes this conversion more consistent when encoding
+  or decoding config, and these values are now maintained.
+# 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/confmap/confmap.go b/confmap/confmap.go
index ec6669b2199..4ec21909805 100644
--- a/confmap/confmap.go
+++ b/confmap/confmap.go
@@ -140,7 +140,12 @@ func sanitizeExpanded(a any, useOriginal bool) any {
 		}
 		return c
 	case []any:
+		// If the value is nil, return nil.
 		var newSlice []any
+		if m == nil {
+			return newSlice
+		}
+		newSlice = make([]any, 0, len(m))
 		for _, e := range m {
 			newSlice = append(newSlice, sanitizeExpanded(e, useOriginal))
 		}
@@ -555,7 +560,13 @@ type Marshaler interface {
 func zeroSliceHookFunc() mapstructure.DecodeHookFuncValue {
 	return func(from reflect.Value, to reflect.Value) (any, error) {
 		if to.CanSet() && to.Kind() == reflect.Slice && from.Kind() == reflect.Slice {
-			to.Set(reflect.MakeSlice(to.Type(), from.Len(), from.Cap()))
+			if from.IsNil() {
+				// input slice is nil, set output slice to nil.
+				to.Set(reflect.Zero(to.Type()))
+			} else {
+				// input slice is not nil, set the output slice to a new slice of the same type.
+				to.Set(reflect.MakeSlice(to.Type(), from.Len(), from.Cap()))
+			}
 		}
 
 		return from.Interface(), nil
diff --git a/confmap/internal/mapstructure/encoder.go b/confmap/internal/mapstructure/encoder.go
index 25eef6cfedb..777dbd92d3d 100644
--- a/confmap/internal/mapstructure/encoder.go
+++ b/confmap/internal/mapstructure/encoder.go
@@ -141,6 +141,11 @@ func (e *Encoder) encodeSlice(value reflect.Value) (any, error) {
 			Kind:   value.Kind(),
 		}
 	}
+
+	if value.IsNil() {
+		return []any(nil), nil
+	}
+
 	result := make([]any, value.Len())
 	for i := 0; i < value.Len(); i++ {
 		var err error


Test patch:
diff --git a/confmap/confmap_test.go b/confmap/confmap_test.go
index f3446b478a7..65be60fcc07 100644
--- a/confmap/confmap_test.go
+++ b/confmap/confmap_test.go
@@ -742,6 +742,28 @@ func TestNilValuesUnchanged(t *testing.T) {
 	require.Equal(t, confFromStruct.ToStringMap(), nilConf.ToStringMap())
 }
 
+func TestEmptySliceUnchanged(t *testing.T) {
+	type structWithSlices struct {
+		Strings []string `mapstructure:"strings"`
+	}
+
+	slicesStruct := &structWithSlices{}
+
+	nilCfg := map[string]any{
+		"strings": []any{},
+	}
+	nilConf := NewFromStringMap(nilCfg)
+	err := nilConf.Unmarshal(slicesStruct)
+	require.NoError(t, err)
+
+	confFromStruct := New()
+	err = confFromStruct.Marshal(slicesStruct)
+	require.NoError(t, err)
+
+	require.Equal(t, nilCfg, nilConf.ToStringMap())
+	require.Equal(t, nilConf.ToStringMap(), confFromStruct.ToStringMap())
+}
+
 type c struct {
 	Modifiers []string `mapstructure:"modifiers"`
 }
diff --git a/confmap/confmaptest/configtest_test.go b/confmap/confmaptest/configtest_test.go
index 75ebd461447..aa73cc970d9 100644
--- a/confmap/confmaptest/configtest_test.go
+++ b/confmap/confmaptest/configtest_test.go
@@ -33,8 +33,7 @@ func TestLoadConf(t *testing.T) {
 func TestToStringMapSanitizeEmptySlice(t *testing.T) {
 	cfg, err := LoadConf(filepath.Join("testdata", "empty-slice.yaml"))
 	require.NoError(t, err)
-	var nilSlice []any
-	assert.Equal(t, map[string]any{"slice": nilSlice}, cfg.ToStringMap())
+	assert.Equal(t, map[string]any{"slice": []any{}}, cfg.ToStringMap())
 }
 
 func TestValidateProviderScheme(t *testing.T) {
diff --git a/service/config_test.go b/service/config_test.go
index d19c3f83d09..97cd6c35c14 100644
--- a/service/config_test.go
+++ b/service/config_test.go
@@ -124,7 +124,7 @@ func TestConfmapMarshalConfig(t *testing.T) {
 									"host": "localhost",
 									"port": 8888,
 									"with_resource_constant_labels": map[string]any{
-										"included": []any(nil),
+										"included": []any{},
 									},
 									"without_scope_info":  true,
 									"without_type_suffix": true,