From d1b8a244da1a8e161470bea4a5eaca69b9e47674 Mon Sep 17 00:00:00 2001 From: Ishai Date: Fri, 14 Nov 2025 18:55:07 -0800 Subject: [PATCH 1/4] add ability to set output_paths --- .chloggen/debug-exporter-output-paths.yaml | 29 +++++++++++++++++++ exporter/debugexporter/README.md | 14 ++++++++- exporter/debugexporter/config.go | 9 ++++++ exporter/debugexporter/config_test.go | 13 +++++++++ exporter/debugexporter/exporter_test.go | 10 +++++++ exporter/debugexporter/factory.go | 9 ++++-- .../testdata/config_output_paths.yaml | 4 +++ 7 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .chloggen/debug-exporter-output-paths.yaml create mode 100644 exporter/debugexporter/testdata/config_output_paths.yaml diff --git a/.chloggen/debug-exporter-output-paths.yaml b/.chloggen/debug-exporter-output-paths.yaml new file mode 100644 index 00000000000..249f5eae0f6 --- /dev/null +++ b/.chloggen/debug-exporter-output-paths.yaml @@ -0,0 +1,29 @@ +# 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. otlpreceiver) +component: exporter/debug + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `output_paths` configuration option to control output destination when `use_internal_logger` is false + +# One or more tracking issues or pull requests related to the change +issues: [10472] + +# (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: | + When `use_internal_logger` is set to `false`, the debug exporter now supports configuring the output destination via the `output_paths` option. + This allows users to send debug exporter output to `stdout`, `stderr`, or a file path. + The default value is `["stdout"]` to maintain backward compatibility. + +# 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/exporter/debugexporter/README.md b/exporter/debugexporter/README.md index 9b6297c2341..eb3b602abfb 100644 --- a/exporter/debugexporter/README.md +++ b/exporter/debugexporter/README.md @@ -38,6 +38,7 @@ The following settings are optional: Refer to [Zap docs](https://godoc.org/go.uber.org/zap/zapcore#NewSampler) for more details on how sampling parameters impact number of messages. - `use_internal_logger` (default = `true`): uses the collector's internal logger for output. See [below](#using-the-collectors-internal-logger) for description. +- `output_paths` (default = `["stdout"]`): a list of URLs or file paths to write logging output to. This option is only used when `use_internal_logger` is `false`. The URLs could only be with "file" schema or without schema. The URLs with "file" schema must be an absolute path. The URLs without schema are treated as local file paths. "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr. - `sending_queue` (disabled by default): see [Sending Queue](../exporterhelper/README.md#sending-queue) for the full set of available options. Example configuration: @@ -50,6 +51,16 @@ exporters: sampling_thereafter: 200 ``` +Example configuration with custom output path: + +```yaml +exporters: + debug: + use_internal_logger: false + output_paths: + - stderr +``` + ## Verbosity levels The following subsections describe the output from the exporter depending on the configured verbosity level - `basic`, `normal` and `detailed`. @@ -138,7 +149,8 @@ This comes with the following consequences: When `use_internal_logger` is set to `false`, the exporter does not use the collector's internal logger. Changing the values in `service::telemetry::logs` has no effect on the exporter's output. -The exporter's output is sent to `stdout`. +The exporter's output is sent to the paths specified in `output_paths` (default: `["stdout"]`). +You can configure `output_paths` to send output to `stderr`, a file, or multiple destinations. [internal_telemetry]: https://opentelemetry.io/docs/collector/internal-telemetry/ [internal_logs_config]: https://opentelemetry.io/docs/collector/internal-telemetry/#configure-internal-logs diff --git a/exporter/debugexporter/config.go b/exporter/debugexporter/config.go index fbf962d7c89..44cef8145a0 100644 --- a/exporter/debugexporter/config.go +++ b/exporter/debugexporter/config.go @@ -33,6 +33,15 @@ type Config struct { // UseInternalLogger defines whether the exporter sends the output to the collector's internal logger. UseInternalLogger bool `mapstructure:"use_internal_logger"` + // OutputPaths is a list of URLs or file paths to write logging output to. + // This option is only used when use_internal_logger is false. + // The URLs could only be with "file" schema or without schema. + // The URLs with "file" schema must be an absolute path. + // The URLs without schema are treated as local file paths. + // "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr. + // (default = ["stdout"]) + OutputPaths []string `mapstructure:"output_paths"` + QueueConfig exporterhelper.QueueBatchConfig `mapstructure:"sending_queue"` // prevent unkeyed literal initialization diff --git a/exporter/debugexporter/config_test.go b/exporter/debugexporter/config_test.go index 51b4c875e79..67c7f086b7b 100644 --- a/exporter/debugexporter/config_test.go +++ b/exporter/debugexporter/config_test.go @@ -37,6 +37,19 @@ func TestUnmarshalConfig(t *testing.T) { Verbosity: configtelemetry.LevelDetailed, SamplingInitial: 10, SamplingThereafter: 50, + UseInternalLogger: false, + OutputPaths: []string{"stdout"}, + QueueConfig: queueCfg, + }, + }, + { + filename: "config_output_paths.yaml", + cfg: &Config{ + Verbosity: configtelemetry.LevelBasic, + SamplingInitial: 2, + SamplingThereafter: 1, + UseInternalLogger: false, + OutputPaths: []string{"stderr"}, QueueConfig: queueCfg, }, }, diff --git a/exporter/debugexporter/exporter_test.go b/exporter/debugexporter/exporter_test.go index 367f5f10eee..766f9ca5bf0 100644 --- a/exporter/debugexporter/exporter_test.go +++ b/exporter/debugexporter/exporter_test.go @@ -124,6 +124,16 @@ func createTestCases() []testCase { return cfg }(), }, + { + name: "custom output paths", + config: func() *Config { + cfg := createDefaultConfig().(*Config) + cfg.QueueConfig.QueueSize = 10 + cfg.UseInternalLogger = false + cfg.OutputPaths = []string{"stderr"} + return cfg + }(), + }, } } diff --git a/exporter/debugexporter/factory.go b/exporter/debugexporter/factory.go index e950d51ba7e..aa3041df03b 100644 --- a/exporter/debugexporter/factory.go +++ b/exporter/debugexporter/factory.go @@ -50,6 +50,7 @@ func createDefaultConfig() component.Config { SamplingInitial: defaultSamplingInitial, SamplingThereafter: defaultSamplingThereafter, UseInternalLogger: true, + OutputPaths: []string{"stdout"}, QueueConfig: queueCfg, } } @@ -128,6 +129,11 @@ func createCustomLogger(exporterConfig *Config) *zap.Logger { encoderConfig.LevelKey = "" // Do not prefix the output with current timestamp. encoderConfig.TimeKey = "" + outputPaths := exporterConfig.OutputPaths + if len(outputPaths) == 0 { + // Default to stdout if not specified + outputPaths = []string{"stdout"} + } zapConfig := zap.Config{ Level: zap.NewAtomicLevelAt(zap.InfoLevel), DisableCaller: true, @@ -137,8 +143,7 @@ func createCustomLogger(exporterConfig *Config) *zap.Logger { }, Encoding: "console", EncoderConfig: encoderConfig, - // Send exporter's output to stdout. This should be made configurable. - OutputPaths: []string{"stdout"}, + OutputPaths: outputPaths, } return zap.Must(zapConfig.Build()) } diff --git a/exporter/debugexporter/testdata/config_output_paths.yaml b/exporter/debugexporter/testdata/config_output_paths.yaml new file mode 100644 index 00000000000..d47368b79ea --- /dev/null +++ b/exporter/debugexporter/testdata/config_output_paths.yaml @@ -0,0 +1,4 @@ +use_internal_logger: false +output_paths: + - stderr + From fbabe6d1bbd6194b76fbdd4ed4f2b6eb790627b1 Mon Sep 17 00:00:00 2001 From: ishaish103 Date: Mon, 17 Nov 2025 09:31:58 -0800 Subject: [PATCH 2/4] Update exporter/debugexporter/README.md Co-authored-by: Andrzej Stencel --- exporter/debugexporter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exporter/debugexporter/README.md b/exporter/debugexporter/README.md index eb3b602abfb..906d994bcb7 100644 --- a/exporter/debugexporter/README.md +++ b/exporter/debugexporter/README.md @@ -38,7 +38,7 @@ The following settings are optional: Refer to [Zap docs](https://godoc.org/go.uber.org/zap/zapcore#NewSampler) for more details on how sampling parameters impact number of messages. - `use_internal_logger` (default = `true`): uses the collector's internal logger for output. See [below](#using-the-collectors-internal-logger) for description. -- `output_paths` (default = `["stdout"]`): a list of URLs or file paths to write logging output to. This option is only used when `use_internal_logger` is `false`. The URLs could only be with "file" schema or without schema. The URLs with "file" schema must be an absolute path. The URLs without schema are treated as local file paths. "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr. +- `output_paths` (default = `["stdout"]`): a list of URLs or file paths to write logging output to. This option is only used when `use_internal_logger` is `false`. The URLs could only be with "file" schema or without schema. The URLs with "file" schema must be an absolute path. The URLs without schema are treated as local file paths. Special strings are "stdout" and "stderr". - `sending_queue` (disabled by default): see [Sending Queue](../exporterhelper/README.md#sending-queue) for the full set of available options. Example configuration: From 374c202d1ef79b2c8c34c7fa1e2f5582f44b3b70 Mon Sep 17 00:00:00 2001 From: Ishai Date: Mon, 17 Nov 2025 09:36:04 -0800 Subject: [PATCH 3/4] fix pr comments --- exporter/debugexporter/README.md | 2 +- exporter/debugexporter/config.go | 13 +++-- exporter/debugexporter/config_test.go | 51 +++++++++++++++++-- .../testdata/config_output_paths_empty.yaml | 3 ++ 4 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 exporter/debugexporter/testdata/config_output_paths_empty.yaml diff --git a/exporter/debugexporter/README.md b/exporter/debugexporter/README.md index 906d994bcb7..984c49e7afa 100644 --- a/exporter/debugexporter/README.md +++ b/exporter/debugexporter/README.md @@ -38,7 +38,7 @@ The following settings are optional: Refer to [Zap docs](https://godoc.org/go.uber.org/zap/zapcore#NewSampler) for more details on how sampling parameters impact number of messages. - `use_internal_logger` (default = `true`): uses the collector's internal logger for output. See [below](#using-the-collectors-internal-logger) for description. -- `output_paths` (default = `["stdout"]`): a list of URLs or file paths to write logging output to. This option is only used when `use_internal_logger` is `false`. The URLs could only be with "file" schema or without schema. The URLs with "file" schema must be an absolute path. The URLs without schema are treated as local file paths. Special strings are "stdout" and "stderr". +- `output_paths` (default = `["stdout"]`): a list of file paths to write logging output to. This option is only used when `use_internal_logger` is `false`. Special strings "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr respectively. All other values are treated as file paths. - `sending_queue` (disabled by default): see [Sending Queue](../exporterhelper/README.md#sending-queue) for the full set of available options. Example configuration: diff --git a/exporter/debugexporter/config.go b/exporter/debugexporter/config.go index 44cef8145a0..92700cb75c8 100644 --- a/exporter/debugexporter/config.go +++ b/exporter/debugexporter/config.go @@ -33,12 +33,10 @@ type Config struct { // UseInternalLogger defines whether the exporter sends the output to the collector's internal logger. UseInternalLogger bool `mapstructure:"use_internal_logger"` - // OutputPaths is a list of URLs or file paths to write logging output to. + // OutputPaths is a list of file paths to write logging output to. // This option is only used when use_internal_logger is false. - // The URLs could only be with "file" schema or without schema. - // The URLs with "file" schema must be an absolute path. - // The URLs without schema are treated as local file paths. - // "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr. + // Special strings "stdout" and "stderr" are interpreted as os.Stdout and os.Stderr respectively. + // All other values are treated as file paths. // (default = ["stdout"]) OutputPaths []string `mapstructure:"output_paths"` @@ -56,5 +54,10 @@ func (cfg *Config) Validate() error { return fmt.Errorf("verbosity level %q is not supported", cfg.Verbosity) } + // If use_internal_logger is false, output_paths must be specified and non-empty + if !cfg.UseInternalLogger && len(cfg.OutputPaths) == 0 { + return fmt.Errorf("output_paths must be specified and non-empty when use_internal_logger is false") + } + return nil } diff --git a/exporter/debugexporter/config_test.go b/exporter/debugexporter/config_test.go index 67c7f086b7b..d35d7d3bb4a 100644 --- a/exporter/debugexporter/config_test.go +++ b/exporter/debugexporter/config_test.go @@ -53,6 +53,10 @@ func TestUnmarshalConfig(t *testing.T) { QueueConfig: queueCfg, }, }, + { + filename: "config_output_paths_empty.yaml", + expectedErr: "output_paths must be specified and non-empty when use_internal_logger is false", + }, { filename: "config_verbosity_typo.yaml", expectedErr: "'' has invalid keys: verBosity", @@ -66,10 +70,24 @@ func TestUnmarshalConfig(t *testing.T) { factory := NewFactory() cfg := factory.CreateDefaultConfig() err = cm.Unmarshal(&cfg) - if tt.expectedErr != "" { - assert.ErrorContains(t, err, tt.expectedErr) - } else { + if err != nil { + if tt.expectedErr != "" { + assert.ErrorContains(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + } + return + } + // Validate the config (validation errors are separate from unmarshal errors) + if cfgCasted, ok := cfg.(*Config); ok { + err = cfgCasted.Validate() + if tt.expectedErr != "" { + assert.ErrorContains(t, err, tt.expectedErr) + return + } require.NoError(t, err) + } + if tt.expectedErr == "" { assert.Equal(t, tt.cfg, cfg) } }) @@ -135,7 +153,30 @@ func TestValidate(t *testing.T) { { name: "verbosity detailed", cfg: &Config{ - Verbosity: configtelemetry.LevelDetailed, + Verbosity: configtelemetry.LevelDetailed, + UseInternalLogger: true, // Default behavior + }, + }, + { + name: "empty output_paths when use_internal_logger is false", + cfg: &Config{ + UseInternalLogger: false, + OutputPaths: []string{}, + }, + expectedErr: "output_paths must be specified and non-empty when use_internal_logger is false", + }, + { + name: "valid output_paths when use_internal_logger is false", + cfg: &Config{ + UseInternalLogger: false, + OutputPaths: []string{"stdout"}, + }, + }, + { + name: "empty output_paths when use_internal_logger is true (allowed)", + cfg: &Config{ + UseInternalLogger: true, + OutputPaths: []string{}, }, }, } @@ -144,7 +185,7 @@ func TestValidate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.cfg.Validate() if tt.expectedErr != "" { - assert.EqualError(t, err, tt.expectedErr) + assert.ErrorContains(t, err, tt.expectedErr) } else { assert.NoError(t, err) } diff --git a/exporter/debugexporter/testdata/config_output_paths_empty.yaml b/exporter/debugexporter/testdata/config_output_paths_empty.yaml new file mode 100644 index 00000000000..af6c8bfa701 --- /dev/null +++ b/exporter/debugexporter/testdata/config_output_paths_empty.yaml @@ -0,0 +1,3 @@ +use_internal_logger: false +output_paths: [] + From c08384d540a07fb2795e2eeec077aea95b75e67e Mon Sep 17 00:00:00 2001 From: Ishai Date: Mon, 24 Nov 2025 21:20:16 -0800 Subject: [PATCH 4/4] add tests --- exporter/debugexporter/factory_test.go | 202 +++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/exporter/debugexporter/factory_test.go b/exporter/debugexporter/factory_test.go index 509228cfb97..c413b8b0a90 100644 --- a/exporter/debugexporter/factory_test.go +++ b/exporter/debugexporter/factory_test.go @@ -5,12 +5,16 @@ package debugexporter import ( "context" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/config/configtelemetry" "go.opentelemetry.io/collector/exporter/exportertest" "go.opentelemetry.io/collector/exporter/xexporter" ) @@ -20,6 +24,12 @@ func TestCreateDefaultConfig(t *testing.T) { cfg := factory.CreateDefaultConfig() assert.NotNil(t, cfg, "failed to create default config") assert.NoError(t, componenttest.CheckConfigStruct(cfg)) + + // Verify default config includes OutputPaths + config := cfg.(*Config) + assert.NotNil(t, config.OutputPaths) + assert.Equal(t, []string{"stdout"}, config.OutputPaths) + assert.True(t, config.UseInternalLogger) } func TestCreateMetrics(t *testing.T) { @@ -57,3 +67,195 @@ func TestCreateFactoryProfiles(t *testing.T) { require.NoError(t, err) assert.NotNil(t, te) } + +func TestCreateCustomLogger(t *testing.T) { + tests := []struct { + name string + config *Config + expectPaths []string + }{ + { + name: "empty output paths defaults to stdout", + config: &Config{ + OutputPaths: []string{}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + expectPaths: []string{"stdout"}, + }, + { + name: "single output path", + config: &Config{ + OutputPaths: []string{"stderr"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + expectPaths: []string{"stderr"}, + }, + { + name: "multiple output paths", + config: &Config{ + OutputPaths: []string{"stdout", "stderr"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + expectPaths: []string{"stdout", "stderr"}, + }, + { + name: "file path", + config: &Config{ + OutputPaths: []string{filepath.Join(t.TempDir(), "test.log")}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + expectPaths: []string{filepath.Join(t.TempDir(), "test.log")}, + }, + { + name: "stdout path", + config: &Config{ + OutputPaths: []string{"stdout"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + expectPaths: []string{"stdout"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := createCustomLogger(tt.config) + require.NotNil(t, logger) + // Verify logger can be used without panicking + logger.Info("test message") + // Sync to ensure all writes are complete + // Note: Sync() may fail for stdout/stderr in test environments, which is acceptable + _ = logger.Sync() + }) + } +} + +func TestCreateLogger(t *testing.T) { + tests := []struct { + name string + config *Config + }{ + { + name: "use internal logger", + config: &Config{ + UseInternalLogger: true, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + }, + { + name: "use custom logger with stdout", + config: &Config{ + UseInternalLogger: false, + OutputPaths: []string{"stdout"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + }, + { + name: "use custom logger with stderr", + config: &Config{ + UseInternalLogger: false, + OutputPaths: []string{"stderr"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + }, + { + name: "use custom logger with file", + config: &Config{ + UseInternalLogger: false, + OutputPaths: []string{filepath.Join(t.TempDir(), "test.log")}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + }, + { + name: "use custom logger with multiple paths", + config: &Config{ + UseInternalLogger: false, + OutputPaths: []string{"stdout", "stderr"}, + SamplingInitial: 2, + SamplingThereafter: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseLogger := zap.NewNop() + logger := createLogger(tt.config, baseLogger) + require.NotNil(t, logger) + // Verify logger can be used without panicking + logger.Info("test message") + // Sync to ensure all writes are complete + // Note: Sync() may fail for stdout/stderr in test environments, which is acceptable + _ = logger.Sync() + }) + } +} + +func TestCreateCustomLoggerWithEmptyPathsFallback(t *testing.T) { + // This test specifically targets the fallback code path in createCustomLogger + // when OutputPaths is empty. Even though validation should prevent this, + // we test the defensive fallback behavior. + config := &Config{ + OutputPaths: []string{}, + SamplingInitial: 2, + SamplingThereafter: 1, + } + + logger := createCustomLogger(config) + require.NotNil(t, logger) + + // Verify the logger works and defaults to stdout + logger.Info("test message") + // Note: Sync() may fail for stdout in test environments, which is acceptable + _ = logger.Sync() +} + +func TestCreateLoggerWithInternalLogger(t *testing.T) { + // Test that createLogger properly uses internal logger when configured + config := &Config{ + UseInternalLogger: true, + SamplingInitial: 10, + SamplingThereafter: 50, + Verbosity: configtelemetry.LevelDetailed, + } + + baseLogger := zap.NewNop() + logger := createLogger(config, baseLogger) + require.NotNil(t, logger) + + // Verify logger can be used + logger.Info("test message") + // Note: Sync() may fail for internal logger in test environments, which is acceptable + _ = logger.Sync() +} + +func TestCreateCustomLoggerWithFileOutput(t *testing.T) { + // Test creating a logger that writes to a file + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "debug.log") + + config := &Config{ + OutputPaths: []string{filePath}, + SamplingInitial: 2, + SamplingThereafter: 1, + } + + logger := createCustomLogger(config) + require.NotNil(t, logger) + + // Write a test message + logger.Info("test message to file") + assert.NoError(t, logger.Sync()) + + // Verify file was created and contains the message + _, err := os.Stat(filePath) + assert.NoError(t, err, "log file should be created") +}