diff --git a/plugins/outputs/opentelemetry/README.md b/plugins/outputs/opentelemetry/README.md index 659a01db1c379..07a39fcdf2981 100644 --- a/plugins/outputs/opentelemetry/README.md +++ b/plugins/outputs/opentelemetry/README.md @@ -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" @@ -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 diff --git a/plugins/outputs/opentelemetry/opentelemetry.go b/plugins/outputs/opentelemetry/opentelemetry.go index 833becbf71681..1ee8b3512a390 100644 --- a/plugins/outputs/opentelemetry/opentelemetry.go +++ b/plugins/outputs/opentelemetry/opentelemetry.go @@ -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"` @@ -82,6 +83,15 @@ func (o *OpenTelemetry) Connect() error { default: return fmt.Errorf("invalid encoding %q", o.EncodingType) } + if o.MetricNameFormat == "" { + o.MetricNameFormat = defaultMetricNameFormat + } + switch o.MetricNameFormat { + 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 } @@ -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) if err != nil { o.Log.Warnf("Failed to add point: %v", err) continue @@ -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" ) func init() { outputs.Add("opentelemetry", func() telegraf.Output { return &OpenTelemetry{ - ServiceAddress: defaultServiceAddress, - Timeout: defaultTimeout, - Compression: defaultCompression, + ServiceAddress: defaultServiceAddress, + Timeout: defaultTimeout, + Compression: defaultCompression, + MetricNameFormat: defaultMetricNameFormat, } }) } diff --git a/plugins/outputs/opentelemetry/opentelemetry_test.go b/plugins/outputs/opentelemetry/opentelemetry_test.go index b74ba43a7ed46..579362b98b984 100644 --- a/plugins/outputs/opentelemetry/opentelemetry_test.go +++ b/plugins/outputs/opentelemetry/opentelemetry_test.go @@ -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) + } + } return pmetricotlp.NewExportResponse(), nil } + +func TestOpenTelemetryMetricNameFormatPrometheus(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{ + 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.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 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()) +} diff --git a/plugins/outputs/opentelemetry/sample.conf b/plugins/outputs/opentelemetry/sample.conf index 77de0fbb4f277..dd36fd6af6cbe 100644 --- a/plugins/outputs/opentelemetry/sample.conf +++ b/plugins/outputs/opentelemetry/sample.conf @@ -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) + # metric_name_format = "prometheus" + ## Override the default (5s) request timeout # timeout = "5s"