diff --git a/docs/user_guide/outputs/otlp_output.md b/docs/user_guide/outputs/otlp_output.md index a8feb753..ebbc69ba 100644 --- a/docs/user_guide/outputs/otlp_output.md +++ b/docs/user_guide/outputs/otlp_output.md @@ -61,7 +61,7 @@ outputs: | `metric-prefix` | unset | Prefix added to generated metric names. | | `append-subscription-name` | `false` | Adds the subscription name to generated metric names. | | `strip-leading-underscore` | `false` | Removes a leading `/` from the gNMI path before `/` is converted to `_`. | -| `strings-as-attributes` | `false` | Exports string values as gauge metrics with value `1` and a `value` data point attribute. If false, string values are dropped. | +| `strings-as-attributes` | `false` | Exports **non-numeric** string values as gauge metrics with value `1` and a `value` data point attribute. Numeric strings (RFC 7951 encodes `uint64`/`int64`/`decimal64` as JSON strings under `json_ietf`) are always emitted as numeric datapoints. If false, non-numeric string values are dropped. | | `resource-tag-keys` | unset | Tags to place on the OTLP Resource instead of the data point. | | `counter-patterns` | unset | Regex patterns matched against value names. Matching values are exported as monotonic cumulative Sums. | | `resource-attributes` | unset | Static attributes added to every OTLP Resource. | diff --git a/pkg/outputs/otlp_output/otlp_converter.go b/pkg/outputs/otlp_output/otlp_converter.go index ca0781fa..4c7be8c9 100644 --- a/pkg/outputs/otlp_output/otlp_converter.go +++ b/pkg/outputs/otlp_output/otlp_converter.go @@ -193,15 +193,21 @@ func (o *otlpOutput) convertEventToMetrics(cfg *config, divergent map[string]boo Name: metricName, } - // Handle string values - switch v := v.(type) { - case string: + // RFC 7951 (JSON_IETF) encodes YANG uint64/int64/decimal64 as JSON + // strings. Prefer a numeric datapoint whenever the string parses as a + // number; only fall back to value=1 + "value" attribute for true enums. + if strVal, ok := v.(string); ok { + if dataPoint := o.createNumberDataPointWithValue(cfg, event, attributes, strVal); dataPoint != nil { + o.assignDataPoint(cfg, metric, k, dataPoint) + result = append(result, metric) + continue + } if !cfg.StringsAsAttributes { - o.logger.Debug("skipping string value (strings-as-attributes=false)", "event", event.Name) + o.logger.Debug("skipping non-numeric string value (strings-as-attributes=false)", "event", event.Name) continue } metric.Data = &metricspb.Metric_Gauge{ - Gauge: o.createGaugeWithString(event, attributes, v), + Gauge: o.createGaugeWithString(event, attributes, strVal), } result = append(result, metric) continue @@ -213,27 +219,32 @@ func (o *otlpOutput) convertEventToMetrics(cfg *config, divergent map[string]boo continue } - if o.isCounter(cfg, k) { - metric.Data = &metricspb.Metric_Sum{ - Sum: &metricspb.Sum{ - AggregationTemporality: metricspb.AggregationTemporality_AGGREGATION_TEMPORALITY_CUMULATIVE, - IsMonotonic: true, - DataPoints: []*metricspb.NumberDataPoint{dataPoint}, - }, - } - } else { - metric.Data = &metricspb.Metric_Gauge{ - Gauge: &metricspb.Gauge{ - DataPoints: []*metricspb.NumberDataPoint{dataPoint}, - }, - } - } + o.assignDataPoint(cfg, metric, k, dataPoint) result = append(result, metric) } return result, nil } +// assignDataPoint sets metric.Data to either a Sum or Gauge based on counter-patterns. +func (o *otlpOutput) assignDataPoint(cfg *config, metric *metricspb.Metric, valueKey string, dataPoint *metricspb.NumberDataPoint) { + if o.isCounter(cfg, valueKey) { + metric.Data = &metricspb.Metric_Sum{ + Sum: &metricspb.Sum{ + AggregationTemporality: metricspb.AggregationTemporality_AGGREGATION_TEMPORALITY_CUMULATIVE, + IsMonotonic: true, + DataPoints: []*metricspb.NumberDataPoint{dataPoint}, + }, + } + return + } + metric.Data = &metricspb.Metric_Gauge{ + Gauge: &metricspb.Gauge{ + DataPoints: []*metricspb.NumberDataPoint{dataPoint}, + }, + } +} + // buildMetricName creates metric name from event and value key // event.Name contains the subscription name (e.g., "nvos", "arista") // valueKey contains the metric path (e.g., "interfaces/interface/state/counters/in-octets") diff --git a/pkg/outputs/otlp_output/otlp_output.go b/pkg/outputs/otlp_output/otlp_output.go index 5b969c8c..01c4fd71 100644 --- a/pkg/outputs/otlp_output/otlp_output.go +++ b/pkg/outputs/otlp_output/otlp_output.go @@ -186,9 +186,11 @@ type config struct { MetricPrefix string `mapstructure:"metric-prefix,omitempty"` // boolean, if true the subscription name will be prepended to the metric name after the prefix. AppendSubscriptionName bool `mapstructure:"append-subscription-name,omitempty"` - // boolean, if true, string type values are exported as gauge metrics with value=1 - // and the string stored as an attribute named "value". - // if false, string values are dropped. + // boolean, if true, non-numeric string values are exported as gauge metrics + // with value=1 and the string stored as an attribute named "value". + // Numeric strings (RFC 7951 encodes uint64/int64/decimal64 as JSON strings) + // are always exported as numeric datapoints regardless of this setting. + // if false, non-numeric string values are dropped. StringsAsAttributes bool `mapstructure:"strings-as-attributes,omitempty"` // boolean, if true, the leading "/" of the metric path is trimmed before the // slash-to-underscore conversion, so a path like "/interfaces/..." becomes diff --git a/pkg/outputs/otlp_output/otlp_output_test.go b/pkg/outputs/otlp_output/otlp_output_test.go index d6cc183e..f013616c 100644 --- a/pkg/outputs/otlp_output/otlp_output_test.go +++ b/pkg/outputs/otlp_output/otlp_output_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/require" metricsv1 "go.opentelemetry.io/proto/otlp/collector/metrics/v1" commonpb "go.opentelemetry.io/proto/otlp/common/v1" + metricspb "go.opentelemetry.io/proto/otlp/metrics/v1" "google.golang.org/grpc" "google.golang.org/grpc/connectivity" "google.golang.org/protobuf/proto" @@ -347,10 +348,7 @@ func TestOTLP_ConfigValidation(t *testing.T) { // Test 7: String Values as Attributes func TestOTLP_StringValuesAsAttributes(t *testing.T) { - t.Skip("Implementation pending") - - // Test strings-as-attributes conversion - // Given: String value metric + // Non-numeric enums stay on the value=1 + "value" attribute path. event := &formatters.EventMsg{ Name: "interfaces_interface_state_oper_status", Timestamp: time.Now().UnixNano(), @@ -362,11 +360,9 @@ func TestOTLP_StringValuesAsAttributes(t *testing.T) { }, } - // When: Converting with strings-as-attributes enabled output := newTestOutput(&config{StringsAsAttributes: true}) otlpMetrics := output.convertToOTLP(output.state.Load().cfg, []*formatters.EventMsg{event}) - // Then: Should create gauge with value=1 and status as attribute metric := otlpMetrics.ResourceMetrics[0].ScopeMetrics[0].Metrics[0] gauge := metric.GetGauge() require.NotNil(t, gauge) @@ -376,6 +372,66 @@ func TestOTLP_StringValuesAsAttributes(t *testing.T) { assert.Equal(t, "up", getDataPointAttribute(dataPoint, "value")) } +// TestOTLP_NumericStringValues_RFC7951 verifies that JSON_IETF-style numeric +// strings (YANG uint64/int64/decimal64) become real numeric datapoints, matching +// prometheus_output behavior, instead of value=1 + a cardinality-exploding label. +func TestOTLP_NumericStringValues_RFC7951(t *testing.T) { + event := &formatters.EventMsg{ + Name: "interface_stats", + Timestamp: time.Now().UnixNano(), + Tags: map[string]string{ + "interface_name": "ethernet-1/1", + "source": "leaf1", + }, + Values: map[string]interface{}{ + "interface/statistics/in-octets": "4597", + "interface/statistics/out-octets": "144925", + "platform/control/memory/free": "9625336000", + }, + } + + output := newTestOutput(&config{StringsAsAttributes: true}) + otlpMetrics := output.convertToOTLP(output.state.Load().cfg, []*formatters.EventMsg{event}) + require.Len(t, otlpMetrics.ResourceMetrics, 1) + metrics := otlpMetrics.ResourceMetrics[0].ScopeMetrics[0].Metrics + require.Len(t, metrics, 3) + + got := map[string]float64{} + for _, m := range metrics { + gauge := m.GetGauge() + require.NotNil(t, gauge, "metric %s should be a gauge", m.Name) + require.Len(t, gauge.DataPoints, 1) + assert.Empty(t, getDataPointAttribute(gauge.DataPoints[0], "value"), + "numeric string must not use value attribute: %s", m.Name) + got[m.Name] = gauge.DataPoints[0].GetAsDouble() + } + + assert.Equal(t, 4597.0, got["interface_statistics_in_octets"]) + assert.Equal(t, 144925.0, got["interface_statistics_out_octets"]) + assert.Equal(t, 9625336000.0, got["platform_control_memory_free"]) +} + +// TestOTLP_NumericStringValues_WithoutStringsAsAttributes ensures RFC7951 +// numeric strings are still emitted when strings-as-attributes is false. +func TestOTLP_NumericStringValues_WithoutStringsAsAttributes(t *testing.T) { + event := &formatters.EventMsg{ + Name: "system_resources", + Timestamp: time.Now().UnixNano(), + Values: map[string]interface{}{ + "memory/free": "1000", + "state": "enabled", // non-numeric; should be dropped + }, + } + + output := newTestOutput(&config{StringsAsAttributes: false}) + otlpMetrics := output.convertToOTLP(output.state.Load().cfg, []*formatters.EventMsg{event}) + require.Len(t, otlpMetrics.ResourceMetrics, 1) + metrics := otlpMetrics.ResourceMetrics[0].ScopeMetrics[0].Metrics + require.Len(t, metrics, 1) + assert.Equal(t, "memory_free", metrics[0].Name) + assert.Equal(t, 1000.0, metrics[0].GetGauge().DataPoints[0].GetAsDouble()) +} + // Test 8: Subscription Name Mapping func TestOTLP_SubscriptionNameMapping(t *testing.T) { t.Skip("Implementation pending") @@ -787,9 +843,13 @@ func getAttributeValue(resource interface{}, key string) string { return "" } -func getDataPointAttribute(dataPoint interface{}, key string) string { - // Helper to extract attribute value from data point - // Will implement when we have the actual OTLP structures +func getDataPointAttribute(dataPoint *metricspb.NumberDataPoint, key string) string { + if dataPoint == nil { + return "" + } + if v, ok := getAttr(dataPoint.Attributes, key); ok { + return v + } return "" }