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
2 changes: 1 addition & 1 deletion docs/user_guide/outputs/otlp_output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
51 changes: 31 additions & 20 deletions pkg/outputs/otlp_output/otlp_converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
8 changes: 5 additions & 3 deletions pkg/outputs/otlp_output/otlp_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 69 additions & 9 deletions pkg/outputs/otlp_output/otlp_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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 ""
}

Expand Down
Loading