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..984c49e7afa 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 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: @@ -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..92700cb75c8 100644 --- a/exporter/debugexporter/config.go +++ b/exporter/debugexporter/config.go @@ -33,6 +33,13 @@ 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 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. + // (default = ["stdout"]) + OutputPaths []string `mapstructure:"output_paths"` + QueueConfig exporterhelper.QueueBatchConfig `mapstructure:"sending_queue"` // prevent unkeyed literal initialization @@ -47,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 51b4c875e79..d35d7d3bb4a 100644 --- a/exporter/debugexporter/config_test.go +++ b/exporter/debugexporter/config_test.go @@ -37,9 +37,26 @@ 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, + }, + }, + { + 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", @@ -53,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) } }) @@ -122,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{}, }, }, } @@ -131,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/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/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") +} 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 + 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: [] +