Files
Dominik SüßandGitHub dbeb33f43a Add endpoint attribute to otlp exporter (#14998)
#### Description

This PR exposes the `endpoint` value of the `otlp` and `otlphttp`
exporter components to the metrics generated by these exporters.

This is useful for operators of the collector to observe data flow to
different endpoints across large numbers of collector instances without
having to cross-reference the specific configuration per instance.

The implementation extends the `exporterhelper` with a new option
`WithAttrs` that allows developers of exporters to add extra labels to
metrics produced. This allows for more flexibility for implementers of
exporters to add attributes that might be relevant in the specific use
case.

<!--Describe what testing was performed and which tests were added.-->
#### Testing

* Added tests to ensure attributes passed with the new option are
recorded correctly
* Tested a build of this branch by configuring the collector to receive
OTLP and export it to another collector. The new `endpoint` shows up
correctly.

<!--Describe the documentation added.-->
#### Documentation

Assuming this change is accepted, I'll create a PR to update the
[internal
telemetry](https://opentelemetry.io/docs/collector/internal-telemetry/)
page in the OpenTelemetry documentation
2026-04-22 09:21:40 +00:00

293 lines
10 KiB
Go

// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package internal
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/component/componenttest"
"go.opentelemetry.io/collector/config/configoptional"
"go.opentelemetry.io/collector/config/configretry"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/metadatatest"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/queuebatch"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/request"
"go.opentelemetry.io/collector/exporter/exporterhelper/internal/requesttest"
"go.opentelemetry.io/collector/exporter/exportertest"
"go.opentelemetry.io/collector/pipeline"
)
func TestBaseExporter(t *testing.T) {
be, err := NewBaseExporter(exportertest.NewNopSettings(exportertest.NopType), pipeline.SignalMetrics, noopExport)
require.NoError(t, err)
require.NoError(t, be.Start(context.Background(), componenttest.NewNopHost()))
require.NoError(t, be.Shutdown(context.Background()))
}
func TestBaseExporterWithOptions(t *testing.T) {
want := errors.New("my error")
be, err := NewBaseExporter(
exportertest.NewNopSettings(exportertest.NopType), pipeline.SignalMetrics, noopExport,
WithStart(func(context.Context, component.Host) error { return want }),
WithShutdown(func(context.Context) error { return want }),
WithTimeout(NewDefaultTimeoutConfig()),
)
require.NoError(t, err)
require.Equal(t, want, be.Start(context.Background(), componenttest.NewNopHost()))
require.Equal(t, want, be.Shutdown(context.Background()))
}
func TestQueueOptionsWithRequestExporter(t *testing.T) {
bs, err := NewBaseExporter(exportertest.NewNopSettings(exportertest.NopType), pipeline.SignalMetrics, noopExport,
WithRetry(configretry.NewDefaultBackOffConfig()))
require.NoError(t, err)
require.Nil(t, bs.queueBatchSettings.Encoding)
_, err = NewBaseExporter(exportertest.NewNopSettings(exportertest.NopType), pipeline.SignalMetrics, noopExport,
WithRetry(configretry.NewDefaultBackOffConfig()), WithQueue(configoptional.Some(NewDefaultQueueConfig())))
require.Error(t, err)
qCfg := NewDefaultQueueConfig()
storageID := component.NewID(component.MustNewType("test"))
qCfg.StorageID = &storageID
_, err = NewBaseExporter(exportertest.NewNopSettings(exportertest.NopType), pipeline.SignalMetrics, noopExport,
WithQueueBatchSettings(newFakeQueueBatch()),
WithRetry(configretry.NewDefaultBackOffConfig()),
WithQueueBatch(configoptional.Some(qCfg), queuebatch.Settings[request.Request]{}))
require.Error(t, err)
}
func TestBaseExporterLogging(t *testing.T) {
set := exportertest.NewNopSettings(exportertest.NopType)
logger, observed := observer.New(zap.DebugLevel)
set.Logger = zap.New(logger)
rCfg := configretry.NewDefaultBackOffConfig()
rCfg.Enabled = false
qCfg := NewDefaultQueueConfig()
qCfg.WaitForResult = true
bs, err := NewBaseExporter(set, pipeline.SignalMetrics, errExport,
WithQueueBatchSettings(newFakeQueueBatch()),
WithQueue(configoptional.Some(qCfg)),
WithRetry(rCfg))
require.NoError(t, err)
require.NoError(t, bs.Start(context.Background(), componenttest.NewNopHost()))
sendErr := bs.Send(context.Background(), &requesttest.FakeRequest{Items: 2})
require.Error(t, sendErr)
errorLogs := observed.FilterLevelExact(zap.ErrorLevel).All()
require.Len(t, errorLogs, 2)
assert.Contains(t, errorLogs[0].Message, "Exporting failed. Dropping data.")
assert.Equal(t, "my error", errorLogs[0].ContextMap()["error"])
assert.Contains(t, errorLogs[1].Message, "Exporting failed. Rejecting data.")
assert.Equal(t, "my error", errorLogs[1].ContextMap()["error"])
require.NoError(t, bs.Shutdown(context.Background()))
}
func TestBaseExporterExtraAttrs(t *testing.T) {
set := exportertest.NewNopSettings(exportertest.NopType)
tt := componenttest.NewTelemetry()
t.Cleanup(func() { require.NoError(t, tt.Shutdown(context.Background())) })
set.MeterProvider = tt.NewTelemetrySettings().MeterProvider
rCfg := configretry.NewDefaultBackOffConfig()
rCfg.Enabled = false
qCfg := NewDefaultQueueConfig()
qCfg.WaitForResult = true
bs, err := NewBaseExporter(set, pipeline.SignalMetrics, errExport,
WithQueueBatchSettings(newFakeQueueBatch()),
WithQueue(configoptional.Some(qCfg)),
WithRetry(rCfg),
WithAttributes(attribute.String("test", "value"), attribute.Bool("other", true)),
)
require.NoError(t, err)
require.NoError(t, bs.Start(context.Background(), componenttest.NewNopHost()))
t.Cleanup(func() {
require.NoError(t, bs.Shutdown(context.Background()))
})
sendErr := bs.Send(context.Background(), &requesttest.FakeRequest{Items: 2})
require.Error(t, sendErr)
metadatatest.AssertEqualExporterSentMetricPoints(t, tt, []metricdata.DataPoint[int64]{
{
Attributes: attribute.NewSet(
attribute.String("exporter", set.ID.String()),
attribute.String("test", "value"),
attribute.Bool("other", true),
),
Value: int64(0),
},
}, metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreExemplars())
}
func TestWithQueue_MetadataKeys(t *testing.T) {
t.Run("with MetadataKeys - configures partitioner and merge function", func(t *testing.T) {
qCfg := NewDefaultQueueConfig()
qCfg.Batch.GetOrInsertDefault().Partition.MetadataKeys = []string{"key1", "key2"}
be, err := NewBaseExporter(
exportertest.NewNopSettings(exportertest.NopType),
pipeline.SignalMetrics,
noopExport,
WithQueueBatchSettings(newFakeQueueBatch()),
WithQueue(configoptional.Some(qCfg)),
)
require.NoError(t, err)
assert.NotNil(t, be)
// Verify partitioner and merge function are configured
assert.NotNil(t, be.queueBatchSettings.Partitioner, "Partitioner should be set when MetadataKeys is provided")
assert.NotNil(t, be.queueBatchSettings.MergeCtx, "MergeCtx should be set when MetadataKeys is provided")
})
t.Run("without MetadataKeys - does not configure partitioner", func(t *testing.T) {
tests := []struct {
name string
metadataKeys []string
}{
{"empty slice", []string{}},
{"nil", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
qCfg := NewDefaultQueueConfig()
qCfg.Batch.GetOrInsertDefault().Partition.MetadataKeys = tt.metadataKeys
be, err := NewBaseExporter(
exportertest.NewNopSettings(exportertest.NopType),
pipeline.SignalMetrics,
noopExport,
WithQueueBatchSettings(newFakeQueueBatch()),
WithQueue(configoptional.Some(qCfg)),
)
require.NoError(t, err)
assert.NotNil(t, be)
// Verify partitioner and merge function are NOT configured
assert.Nil(t, be.queueBatchSettings.Partitioner, "Partitioner should not be set when MetadataKeys is %s", tt.name)
assert.Nil(t, be.queueBatchSettings.MergeCtx, "MergeCtx should not be set when MetadataKeys is %s", tt.name)
})
}
})
t.Run("error when custom partitioner already set and metadata_keys used", func(t *testing.T) {
qCfg := NewDefaultQueueConfig()
qCfg.Batch.GetOrInsertDefault().Partition.MetadataKeys = []string{"key1", "key2"}
// Set up queue batch settings with a custom partitioner already configured
customSettings := newFakeQueueBatch()
customPartitioner := queuebatch.NewPartitioner(
func(context.Context, request.Request) string {
return "custom"
},
)
customSettings.Partitioner = customPartitioner
_, err := NewBaseExporter(
exportertest.NewNopSettings(exportertest.NopType),
pipeline.SignalMetrics,
noopExport,
WithQueueBatchSettings(customSettings),
WithQueue(configoptional.Some(qCfg)),
)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use metadata_keys when a custom partitioner is already configured")
})
t.Run("error when custom merge function already set and metadata_keys used", func(t *testing.T) {
qCfg := NewDefaultQueueConfig()
qCfg.Batch.GetOrInsertDefault().Partition.MetadataKeys = []string{"key1", "key2"}
// Set up queue batch settings with a custom merge function already configured
customSettings := newFakeQueueBatch()
customSettings.MergeCtx = func(context.Context, context.Context) context.Context {
return context.Background()
}
_, err := NewBaseExporter(
exportertest.NewNopSettings(exportertest.NopType),
pipeline.SignalMetrics,
noopExport,
WithQueueBatchSettings(customSettings),
WithQueue(configoptional.Some(qCfg)),
)
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use metadata_keys when a custom merge function is already configured")
})
}
func TestQueueRetryWithDisabledQueue(t *testing.T) {
tests := []struct {
name string
queueOptions []Option
}{
{
name: "WithQueue",
queueOptions: []Option{
WithQueueBatchSettings(newFakeQueueBatch()),
func() Option {
return WithQueue(configoptional.None[queuebatch.Config]())
}(),
},
},
{
name: "WithRequestQueue",
queueOptions: []Option{
func() Option {
return WithQueueBatch(configoptional.None[queuebatch.Config](), newFakeQueueBatch())
}(),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
set := exportertest.NewNopSettings(exportertest.NopType)
logger, observed := observer.New(zap.ErrorLevel)
set.Logger = zap.New(logger)
be, err := NewBaseExporter(set, pipeline.SignalLogs, errExport, tt.queueOptions...)
require.NoError(t, err)
require.NoError(t, be.Start(context.Background(), componenttest.NewNopHost()))
mockR := &requesttest.FakeRequest{Items: 2}
require.Error(t, be.Send(context.Background(), mockR))
assert.Len(t, observed.All(), 1)
assert.Equal(t, "Exporting failed. Rejecting data. Try enabling sending_queue to survive temporary failures.", observed.All()[0].Message)
require.NoError(t, be.Shutdown(context.Background()))
})
}
}
func errExport(context.Context, request.Request) error {
return errors.New("my error")
}
func noopExport(context.Context, request.Request) error {
return nil
}
func newFakeQueueBatch() queuebatch.Settings[request.Request] {
return queuebatch.Settings[request.Request]{
Encoding: fakeEncoding{},
}
}
type fakeEncoding struct{}
func (f fakeEncoding) Marshal(context.Context, request.Request) ([]byte, error) {
return []byte("mockRequest"), nil
}
func (f fakeEncoding) Unmarshal([]byte) (context.Context, request.Request, error) {
return context.Background(), &requesttest.FakeRequest{}, nil
}