Repository navigation
feat(output): fan out one stream to multiple outputs (PIPE-1446) #315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dylanmyers/pipe-1492-output-signal-interface-reshape-writer-base-optional
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/spf13/viper" | ||
| ) | ||
|
|
||
| // ValidateExclusiveOutputConfig rejects a config that sets both the singular | ||
| // `output` and the plural `outputs`. The two together are ambiguous: `outputs` | ||
| // wins for what runs, while `output` still seeds each entry's defaults, so a | ||
| // value on `output` would leak silently into the list. Callers must pick one. | ||
| // | ||
| // It runs at load against the parsed config sources (`InConfig`), not | ||
| // `IsSet`, so the defaults bound for `output.*` do not make it fire when only | ||
| // `outputs` is written. | ||
| func ValidateExclusiveOutputConfig(v *viper.Viper) error { | ||
| return rejectBothForms(v, "output", "outputs") | ||
| } | ||
|
|
||
| // rejectBothForms errors when both the singular and plural forms of a config | ||
| // key are present in the parsed config. Shared by the output check here and, | ||
| // later, the generator/generators check. | ||
| func rejectBothForms(v *viper.Viper, singular, plural string) error { | ||
| if v.InConfig(singular) && v.InConfig(plural) { | ||
| return fmt.Errorf("set either %s or %s, not both", singular, plural) | ||
| } | ||
| return nil | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/spf13/viper" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func viperFrom(t *testing.T, yaml string) *viper.Viper { | ||
| t.Helper() | ||
| v := viper.New() | ||
| v.SetConfigType("yaml") | ||
| require.NoError(t, v.ReadConfig(strings.NewReader(yaml))) | ||
| return v | ||
| } | ||
|
|
||
| func TestValidateExclusiveOutputConfig_BothSetFails(t *testing.T) { | ||
| v := viperFrom(t, "output:\n type: stdout\noutputs:\n - type: nop\n") | ||
| require.Error(t, ValidateExclusiveOutputConfig(v)) | ||
| } | ||
|
|
||
| func TestValidateExclusiveOutputConfig_OnlyOutputsOK(t *testing.T) { | ||
| v := viperFrom(t, "outputs:\n - type: nop\n") | ||
| require.NoError(t, ValidateExclusiveOutputConfig(v)) | ||
| } | ||
|
|
||
| func TestValidateExclusiveOutputConfig_OnlyOutputOK(t *testing.T) { | ||
| v := viperFrom(t, "output:\n type: stdout\n") | ||
| require.NoError(t, ValidateExclusiveOutputConfig(v)) | ||
| } | ||
|
|
||
| func TestValidateExclusiveOutputConfig_NeitherOK(t *testing.T) { | ||
| v := viperFrom(t, "logging:\n type: stdout\n") | ||
| require.NoError(t, ValidateExclusiveOutputConfig(v)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestEffectiveOutputs_SingleOutput(t *testing.T) { | ||
| cfg := &Config{Output: Output{Type: OutputTypeStdout}} | ||
|
|
||
| outs := cfg.EffectiveOutputs() | ||
| require.Len(t, outs, 1) | ||
| assert.Equal(t, OutputTypeStdout, outs[0].Type) | ||
| } | ||
|
|
||
| func TestEffectiveOutputs_MultiOutput(t *testing.T) { | ||
| cfg := &Config{ | ||
| Outputs: []Output{ | ||
| {Type: OutputTypeStdout}, | ||
| {Type: OutputTypeNop}, | ||
| }, | ||
| } | ||
|
|
||
| outs := cfg.EffectiveOutputs() | ||
| require.Len(t, outs, 2) | ||
| assert.Equal(t, OutputTypeStdout, outs[0].Type) | ||
| assert.Equal(t, OutputTypeNop, outs[1].Type) | ||
| } | ||
|
|
||
| func TestEffectiveOutputs_MultiOverridesSingle(t *testing.T) { | ||
| cfg := &Config{ | ||
| Output: Output{Type: OutputTypeStdout}, | ||
| Outputs: []Output{{Type: OutputTypeNop}, {Type: OutputTypeFile}}, | ||
| } | ||
|
|
||
| outs := cfg.EffectiveOutputs() | ||
| require.Len(t, outs, 2, "Outputs field should take precedence") | ||
| } | ||
|
|
||
| func TestEffectiveOutputs_ListEntriesInheritDefaults(t *testing.T) { | ||
| // c.Output is the viper-defaulted template (workers filled). A list entry | ||
| // that omits workers must inherit that default; user-set fields win. | ||
| cfg := &Config{ | ||
| Output: Output{TCP: TCPOutputConfig{Workers: 1}}, | ||
| Outputs: []Output{ | ||
| {Type: OutputTypeTCP, TCP: TCPOutputConfig{Host: "h", Port: 5000}}, | ||
| }, | ||
| } | ||
|
|
||
| outs := cfg.EffectiveOutputs() | ||
| require.Len(t, outs, 1) | ||
| assert.Equal(t, 1, outs[0].TCP.Workers, "unset workers should inherit the template default") | ||
| assert.Equal(t, "h", outs[0].TCP.Host, "user-set host must be preserved") | ||
| assert.Equal(t, 5000, outs[0].TCP.Port, "user-set port must be preserved") | ||
| } | ||
|
|
||
| func TestEffectiveOutputs_ListEntryUserValueOverridesDefault(t *testing.T) { | ||
| cfg := &Config{ | ||
| Output: Output{TCP: TCPOutputConfig{Workers: 1}}, | ||
| Outputs: []Output{ | ||
| {Type: OutputTypeTCP, TCP: TCPOutputConfig{Host: "h", Port: 5000, Workers: 8}}, | ||
| }, | ||
| } | ||
|
|
||
| outs := cfg.EffectiveOutputs() | ||
| assert.Equal(t, 8, outs[0].TCP.Workers, "explicit workers must win over the template default") | ||
| } | ||
|
|
||
| func TestValidate_OutputsListValidatesEach(t *testing.T) { | ||
| cfg := &Config{ | ||
| Logging: Logging{Type: LoggingTypeStdout}, | ||
| Outputs: []Output{ | ||
| {Type: OutputTypeNop}, | ||
| {Type: OutputType("bogus")}, | ||
| }, | ||
| } | ||
|
|
||
| err := cfg.Validate() | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "outputs[1]") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package output | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
|
|
||
| "github.com/observiq/blitz/telemetry" | ||
| ) | ||
|
|
||
| // MultiOutput fans one generated stream out to several child outputs, | ||
| // implementing Output plus MetricWriter and TraceWriter so callers treat it | ||
| // like a single output. It records no telemetry itself; each child emits its | ||
| // own output_type-tagged metrics, so there is no double-counting. | ||
| type MultiOutput struct { | ||
| outputs []Output | ||
| } | ||
|
|
||
| // NewMultiOutput wraps the given outputs. | ||
| func NewMultiOutput(outputs ...Output) *MultiOutput { | ||
| return &MultiOutput{outputs: outputs} | ||
| } | ||
|
Comment on lines
+14
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fan-out is serial, so a hanging child throttles the others and uses up the shared 5s timeout. The docs only promise that a dead destination doesn't stop the others. |
||
|
|
||
| // WriteLog fans a log record to children that implement LogWriter and | ||
| // declare Logs; others are skipped. Best-effort: a failing child does not | ||
| // stop the others; the aggregated error is returned (nil if all succeed). | ||
| func (m *MultiOutput) WriteLog(ctx context.Context, data LogRecord) error { | ||
| var errs []error | ||
| for _, o := range m.outputs { | ||
| w, ok := o.(LogWriter) | ||
| if !ok || !supports(o, telemetry.Logs) { | ||
| continue | ||
| } | ||
| if err := w.WriteLog(ctx, data); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| // WriteMetric fans a metric record to children that implement MetricWriter | ||
| // and declare Metrics; others are skipped. | ||
| func (m *MultiOutput) WriteMetric(ctx context.Context, data MetricRecord) error { | ||
| var errs []error | ||
| for _, o := range m.outputs { | ||
| w, ok := o.(MetricWriter) | ||
| if !ok || !supports(o, telemetry.Metrics) { | ||
| continue | ||
| } | ||
| if err := w.WriteMetric(ctx, data); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| // WriteTrace fans a trace record to children that implement TraceWriter and | ||
| // declare Traces; others are skipped. | ||
| func (m *MultiOutput) WriteTrace(ctx context.Context, data TraceRecord) error { | ||
| var errs []error | ||
| for _, o := range m.outputs { | ||
| w, ok := o.(TraceWriter) | ||
| if !ok || !supports(o, telemetry.Traces) { | ||
| continue | ||
| } | ||
| if err := w.WriteTrace(ctx, data); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| // Stop stops every child, aggregating errors; one failure does not skip the rest. | ||
| func (m *MultiOutput) Stop(ctx context.Context) error { | ||
| var errs []error | ||
| for _, o := range m.outputs { | ||
| if err := o.Stop(ctx); err != nil { | ||
| errs = append(errs, err) | ||
| } | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| // SupportedTelemetry returns the union of the children's supported types. | ||
| func (m *MultiOutput) SupportedTelemetry() []telemetry.Type { | ||
| seen := make(map[telemetry.Type]struct{}) | ||
| var union []telemetry.Type | ||
| for _, o := range m.outputs { | ||
| for _, t := range o.SupportedTelemetry() { | ||
| if _, dup := seen[t]; dup { | ||
| continue | ||
| } | ||
| seen[t] = struct{}{} | ||
| union = append(union, t) | ||
| } | ||
| } | ||
| return union | ||
| } | ||
|
|
||
| // supports reports whether the output declares the given telemetry type. | ||
| func supports(o Output, t telemetry.Type) bool { | ||
| for _, s := range o.SupportedTelemetry() { | ||
| if s == t { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
Comment on lines
+100
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. supports() calls SupportedTelemetry() on every write, which costs one extra allocation per child per record. Precomputing per-signal child lists at construction avoids it. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fillZeroFieldstreats a zero value as "unset", so an explicitfalseor0in an outputs: entry is overwritten by the singularoutputdefault. There are two real cases: syslog.facility: 0 (kern, and validation accepts 0–23) comes out as 1, and file.rotation.compress: false comes out as true. YAML decoding produces the same zero values, so real configs hit this. The struct can't tell "unset" from "explicit zero".