Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions plugins/outputs/opentelemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details.
## protobuf, json
# encoding_type = "protobuf"

## Override the default (prometheus) metric name format
## prometheus: converts dots to underscores (default, Prometheus-compatible)
## otel: preserves dot-separated names (OpenTelemetry semantic conventions)
# metric_name_format = "prometheus"

## Override the default (5s) request timeout
# timeout = "5s"

Expand Down Expand Up @@ -74,6 +79,21 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details.
# key1 = "value1"
```

## Metric Name Format

The plugin supports two metric name formats:

- **prometheus** (default): Converts dots (`.`) to underscores (`_`) in metric names.
This is the default behavior and maintains compatibility with Prometheus naming
conventions.
- **otel**: Preserves dot-separated metric names as-is. This is useful when working
with OpenTelemetry semantic conventions (e.g., `http.server.duration`,
`http.client.request.count`) and ensures consistency across the OpenTelemetry
ecosystem.

To use OpenTelemetry semantic conventions, set `metric_name_format = "otel"` in
your configuration.

## Supported dialects

### Coralogix
Expand Down
49 changes: 40 additions & 9 deletions plugins/outputs/opentelemetry/opentelemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ var userAgent = internal.ProductToken()
var sampleConfig string

type OpenTelemetry struct {
ServiceAddress string `toml:"service_address"`
EncodingType string `toml:"encoding_type"`
ServiceAddress string `toml:"service_address"`
EncodingType string `toml:"encoding_type"`
MetricNameFormat string `toml:"metric_name_format"`

tls.ClientConfig
Timeout config.Duration `toml:"timeout"`
Expand Down Expand Up @@ -82,6 +83,15 @@ func (o *OpenTelemetry) Connect() error {
default:
return fmt.Errorf("invalid encoding %q", o.EncodingType)
}
if o.MetricNameFormat == "" {
o.MetricNameFormat = defaultMetricNameFormat
}
Comment on lines +86 to +88
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this change the existing behavior? In the existing code we do not replace anything in the metric name, do we?

switch o.MetricNameFormat {
case "prometheus", "otel":
Comment on lines +86 to +90
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if o.MetricNameFormat == "" {
o.MetricNameFormat = defaultMetricNameFormat
}
switch o.MetricNameFormat {
case "prometheus", "otel":
switch o.MetricNameFormat {
case "":
o.MetricNameFormat = "prometheus"
case "prometheus", "otel":

// Valid formats
default:
return fmt.Errorf("invalid metric_name_format %q, must be 'prometheus' or 'otel'", o.MetricNameFormat)
}
if o.Timeout <= 0 {
o.Timeout = defaultTimeout
}
Expand Down Expand Up @@ -178,7 +188,8 @@ func (o *OpenTelemetry) sendBatch(metrics []telegraf.Metric) error {
o.Log.Warnf("Unrecognized metric type %v", metric.Type())
continue
}
err := batch.AddPoint(metric.Name(), metric.Tags(), metric.Fields(), metric.Time(), vType)
metricName := o.transformMetricName(metric.Name())
err := batch.AddPoint(metricName, metric.Tags(), metric.Fields(), metric.Time(), vType)
Comment on lines +191 to +192
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fold the code in here

Suggested change
metricName := o.transformMetricName(metric.Name())
err := batch.AddPoint(metricName, metric.Tags(), metric.Fields(), metric.Time(), vType)
// Transform the metric name if required
name := metric.Name()
switch o.MetricNameFormat
case "prometheus":
// Prometheus doesn't like dots
name = strings.ReplaceAll(name, ".", "_")
}
err := batch.AddPoint(name, metric.Tags(), metric.Fields(), metric.Time(), vType)

if err != nil {
o.Log.Warnf("Failed to add point: %v", err)
continue
Expand Down Expand Up @@ -208,18 +219,38 @@ func (o *OpenTelemetry) sendBatch(metrics []telegraf.Metric) error {
return err
}

// transformMetricName transforms metric names based on the configured format.
// For "otel" format, it preserves dots (OpenTelemetry semantic conventions).
// For "prometheus" format (default), it converts dots to underscores.
func (o *OpenTelemetry) transformMetricName(name string) string {
// If MetricNameFormat is not set, default to prometheus behavior for backward compatibility
format := o.MetricNameFormat
if format == "" {
format = defaultMetricNameFormat
}

if format == "otel" {
// Preserve dots for OpenTelemetry semantic conventions
return name
}
// Default "prometheus" format: convert dots to underscores
return strings.ReplaceAll(name, ".", "_")
}

const (
defaultServiceAddress = "localhost:4317"
defaultTimeout = config.Duration(5 * time.Second)
defaultCompression = "gzip"
defaultServiceAddress = "localhost:4317"
defaultTimeout = config.Duration(5 * time.Second)
defaultCompression = "gzip"
defaultMetricNameFormat = "prometheus"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not add constants for this! There is no benefit really!

)

func init() {
outputs.Add("opentelemetry", func() telegraf.Output {
return &OpenTelemetry{
ServiceAddress: defaultServiceAddress,
Timeout: defaultTimeout,
Compression: defaultCompression,
ServiceAddress: defaultServiceAddress,
Timeout: defaultTimeout,
Compression: defaultCompression,
MetricNameFormat: defaultMetricNameFormat,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be set in Init()!

}
})
}
226 changes: 224 additions & 2 deletions plugins/outputs/opentelemetry/opentelemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,230 @@ func (m *mockOtelService) Address() string {
func (m *mockOtelService) Export(ctx context.Context, request pmetricotlp.ExportRequest) (pmetricotlp.ExportResponse, error) {
m.metrics = pmetric.NewMetrics()
request.Metrics().CopyTo(m.metrics)
// Only check metadata if it exists (for tests that provide headers)
ctxMetadata, ok := metadata.FromIncomingContext(ctx)
require.Equal(m.t, []string{"header1"}, ctxMetadata.Get("test"))
require.True(m.t, ok)
if ok {
if testHeader := ctxMetadata.Get("test"); len(testHeader) > 0 {
require.Equal(m.t, []string{"header1"}, testHeader)
}
}
Comment on lines +345 to +351
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change necessary?

return pmetricotlp.NewExportResponse(), nil
}

func TestOpenTelemetryMetricNameFormatPrometheus(t *testing.T) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about

Suggested change
func TestOpenTelemetryMetricNameFormatPrometheus(t *testing.T) {
func TestNameFormatPrometheus(t *testing.T) {

expect := pmetric.NewMetrics()
{
rm := expect.ResourceMetrics().AppendEmpty()
ilm := rm.ScopeMetrics().AppendEmpty()
m := ilm.Metrics().AppendEmpty()
m.SetName("http_server_duration") // dots converted to underscores
m.SetEmptyGauge()
dp := m.Gauge().DataPoints().AppendEmpty()
dp.SetTimestamp(pcommon.Timestamp(1622848686000000000))
dp.SetDoubleValue(87.332)
}
m := newMockOtelService(t)
t.Cleanup(m.Cleanup)

metricsConverter, err := influx2otel.NewLineProtocolToOtelMetrics(common.NoopLogger{})
require.NoError(t, err)
plugin := &OpenTelemetry{
Comment on lines +371 to +372
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
require.NoError(t, err)
plugin := &OpenTelemetry{
require.NoError(t, err)
plugin := &OpenTelemetry{

ServiceAddress: m.Address(),
Timeout: config.Duration(time.Second),
MetricNameFormat: "prometheus",
metricsConverter: metricsConverter,
otlpMetricClient: &gRPCClient{
grpcClientConn: m.GrpcClient(),
metricsServiceClient: pmetricotlp.NewGRPCClient(m.GrpcClient()),
},
Log: testutil.Logger{},
}

input := testutil.MustMetric(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid testutil.MustMetric and use metric.New instead. The former will be gone soon...

Suggested change
input := testutil.MustMetric(
input := metric.New(

Same for all other instances below.

"http.server.duration", // dot-separated name
map[string]string{},
map[string]interface{}{
"gauge": 87.332,
},
time.Unix(0, 1622848686000000000),
)

require.NoError(t, plugin.Write([]telegraf.Metric{input}))

got := m.GotMetrics()
require.Equal(t, 1, got.ResourceMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().Len())
require.Equal(t, "http_server_duration", got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Name())
Comment on lines +395 to +399
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other tests compare the JSON representation of the metric. Can we please stick to this? Same for the other tests below.

}

func TestOpenTelemetryMetricNameFormatOtel(t *testing.T) {
expect := pmetric.NewMetrics()
{
rm := expect.ResourceMetrics().AppendEmpty()
ilm := rm.ScopeMetrics().AppendEmpty()
m := ilm.Metrics().AppendEmpty()
m.SetName("http.server.duration") // dots preserved
m.SetEmptyGauge()
dp := m.Gauge().DataPoints().AppendEmpty()
dp.SetTimestamp(pcommon.Timestamp(1622848686000000000))
dp.SetDoubleValue(87.332)
}
m := newMockOtelService(t)
t.Cleanup(m.Cleanup)

metricsConverter, err := influx2otel.NewLineProtocolToOtelMetrics(common.NoopLogger{})
require.NoError(t, err)
plugin := &OpenTelemetry{
ServiceAddress: m.Address(),
Timeout: config.Duration(time.Second),
MetricNameFormat: "otel",
metricsConverter: metricsConverter,
otlpMetricClient: &gRPCClient{
grpcClientConn: m.GrpcClient(),
metricsServiceClient: pmetricotlp.NewGRPCClient(m.GrpcClient()),
},
Log: testutil.Logger{},
}

input := testutil.MustMetric(
"http.server.duration", // dot-separated name
map[string]string{},
map[string]interface{}{
"gauge": 87.332,
},
time.Unix(0, 1622848686000000000),
)

require.NoError(t, plugin.Write([]telegraf.Metric{input}))

got := m.GotMetrics()
require.Equal(t, 1, got.ResourceMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().Len())
require.Equal(t, "http.server.duration", got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Name())
}

func TestOpenTelemetryMetricNameFormatDefault(t *testing.T) {
expect := pmetric.NewMetrics()
{
rm := expect.ResourceMetrics().AppendEmpty()
ilm := rm.ScopeMetrics().AppendEmpty()
m := ilm.Metrics().AppendEmpty()
m.SetName("http_server_duration") // default is prometheus format
m.SetEmptyGauge()
dp := m.Gauge().DataPoints().AppendEmpty()
dp.SetTimestamp(pcommon.Timestamp(1622848686000000000))
dp.SetDoubleValue(87.332)
}
m := newMockOtelService(t)
t.Cleanup(m.Cleanup)

metricsConverter, err := influx2otel.NewLineProtocolToOtelMetrics(common.NoopLogger{})
require.NoError(t, err)
plugin := &OpenTelemetry{
ServiceAddress: m.Address(),
Timeout: config.Duration(time.Second),
MetricNameFormat: "", // empty should default to prometheus
metricsConverter: metricsConverter,
otlpMetricClient: &gRPCClient{
grpcClientConn: m.GrpcClient(),
metricsServiceClient: pmetricotlp.NewGRPCClient(m.GrpcClient()),
},
Log: testutil.Logger{},
}
require.NoError(t, plugin.Connect()) // Connect sets default

input := testutil.MustMetric(
"http.server.duration", // dot-separated name
map[string]string{},
map[string]interface{}{
"gauge": 87.332,
},
time.Unix(0, 1622848686000000000),
)

require.NoError(t, plugin.Write([]telegraf.Metric{input}))

got := m.GotMetrics()
require.Equal(t, 1, got.ResourceMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().Len())
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().Len())
require.Equal(t, "http_server_duration", got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Name())
}

func TestOpenTelemetryInvalidMetricNameFormat(t *testing.T) {
plugin := &OpenTelemetry{
ServiceAddress: "localhost:4317",
MetricNameFormat: "invalid",
Log: testutil.Logger{},
}
err := plugin.Connect()
require.Error(t, err)
require.Contains(t, err.Error(), "invalid metric_name_format")
}

func TestOpenTelemetryMetricNameWithUnderscores(t *testing.T) {
// Test that existing underscores are preserved in both formats
m := newMockOtelService(t)
t.Cleanup(m.Cleanup)

metricsConverter, err := influx2otel.NewLineProtocolToOtelMetrics(common.NoopLogger{})
require.NoError(t, err)

// Test prometheus format - underscores should remain, dots should be converted
plugin := &OpenTelemetry{
ServiceAddress: m.Address(),
Timeout: config.Duration(time.Second),
MetricNameFormat: "prometheus",
metricsConverter: metricsConverter,
otlpMetricClient: &gRPCClient{
grpcClientConn: m.GrpcClient(),
metricsServiceClient: pmetricotlp.NewGRPCClient(m.GrpcClient()),
},
Log: testutil.Logger{},
}

input := testutil.MustMetric(
"http.server.request.duration", // dots and underscores
map[string]string{},
map[string]interface{}{
"gauge": 87.332,
},
time.Unix(0, 1622848686000000000),
)

require.NoError(t, plugin.Write([]telegraf.Metric{input}))
got := m.GotMetrics()
require.Equal(t, 1, got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().Len())
// Dots should be converted to underscores
require.Equal(t, "http_server_request_duration", got.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Name())

// Test otel format - everything should be preserved
plugin2 := &OpenTelemetry{
ServiceAddress: m.Address(),
Timeout: config.Duration(time.Second),
MetricNameFormat: "otel",
metricsConverter: metricsConverter,
otlpMetricClient: &gRPCClient{
grpcClientConn: m.GrpcClient(),
metricsServiceClient: pmetricotlp.NewGRPCClient(m.GrpcClient()),
},
Log: testutil.Logger{},
}

input2 := testutil.MustMetric(
"http.server.request.duration",
map[string]string{},
map[string]interface{}{
"gauge": 87.332,
},
time.Unix(0, 1622848686000000000),
)

require.NoError(t, plugin2.Write([]telegraf.Metric{input2}))
got2 := m.GotMetrics()
require.Equal(t, 1, got2.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().Len())
// Dots should be preserved
require.Equal(t, "http.server.request.duration", got2.ResourceMetrics().At(0).ScopeMetrics().At(0).Metrics().At(0).Name())
}
5 changes: 5 additions & 0 deletions plugins/outputs/opentelemetry/sample.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
## protobuf, json
# encoding_type = "protobuf"

## Override the default (prometheus) metric name format
## prometheus: converts dots to underscores (default, Prometheus-compatible)
## otel: preserves dot-separated names (OpenTelemetry semantic conventions)
Comment on lines +11 to +13
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please be brief, defaults are documented in the option!

Suggested change
## Override the default (prometheus) metric name format
## prometheus: converts dots to underscores (default, Prometheus-compatible)
## otel: preserves dot-separated names (OpenTelemetry semantic conventions)
## Transform metric names according to the given format, available options:
## prometheus -- converts dots to underscores
## otel -- keep metric names as-is

# metric_name_format = "prometheus"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the code before this PR, I don't see where we replace dots with underscores, so you are changing the default behavior and break everyone using this plugin. Am I missing anything?


## Override the default (5s) request timeout
# timeout = "5s"

Expand Down
Loading