diff --git a/plugins/common/opcua/input/input_client.go b/plugins/common/opcua/input/input_client.go index 411673945..3285d02fe 100644 --- a/plugins/common/opcua/input/input_client.go +++ b/plugins/common/opcua/input/input_client.go @@ -288,6 +288,7 @@ type NodeValue struct { ServerTime time.Time SourceTime time.Time DataType ua.TypeID + IsArray bool } // OpcUAInputClient can receive data from an OPC UA server and map it to Metrics. This type does not contain @@ -527,6 +528,7 @@ func (o *OpcUAInputClient) UpdateNodeValue(nodeIdx int, d *ua.DataValue) { if d.Value != nil { o.LastReceivedData[nodeIdx].DataType = d.Value.Type() + o.LastReceivedData[nodeIdx].IsArray = d.Value.Has(ua.VariantArrayValues) o.LastReceivedData[nodeIdx].Value = d.Value.Value() if o.LastReceivedData[nodeIdx].DataType == ua.TypeIDDateTime { @@ -541,7 +543,6 @@ func (o *OpcUAInputClient) UpdateNodeValue(nodeIdx int, d *ua.DataValue) { func (o *OpcUAInputClient) MetricForNode(nodeIdx int) telegraf.Metric { nmm := &o.NodeMetricMapping[nodeIdx] - fields := make(map[string]interface{}) tags := map[string]string{ "id": nmm.idStr, } @@ -549,7 +550,47 @@ func (o *OpcUAInputClient) MetricForNode(nodeIdx int) telegraf.Metric { tags[k] = v } - fields[nmm.Tag.FieldName] = o.LastReceivedData[nodeIdx].Value + fields := make(map[string]interface{}) + if o.LastReceivedData[nodeIdx].Value != nil { + // Simple scalar types can be stored directly under the field name while + // arrays (see 5.2.5) and structures (see 5.2.6) must be unpacked. + // Note: Structures and arrays of structures are currently not supported. + if o.LastReceivedData[nodeIdx].IsArray { + switch typedValue := o.LastReceivedData[nodeIdx].Value.(type) { + case []uint8: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []uint16: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []uint32: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []uint64: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []int8: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []int16: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []int32: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []int64: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []float32: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []float64: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []string: + fields = unpack(nmm.Tag.FieldName, typedValue) + case []bool: + fields = unpack(nmm.Tag.FieldName, typedValue) + default: + o.Log.Errorf("could not unpack variant array of type: %T", typedValue) + } + } else { + fields = map[string]interface{}{ + nmm.Tag.FieldName: o.LastReceivedData[nodeIdx].Value, + } + } + } + fields["Quality"] = strings.TrimSpace(o.LastReceivedData[nodeIdx].Quality.Error()) if choice.Contains("DataType", o.Config.OptionalFields) { fields["DataType"] = strings.Replace(o.LastReceivedData[nodeIdx].DataType.String(), "TypeID", "", 1) @@ -573,6 +614,15 @@ func (o *OpcUAInputClient) MetricForNode(nodeIdx int) telegraf.Metric { return metric.New(nmm.metricName, tags, fields, t) } +func unpack[Slice ~[]E, E any](prefix string, value Slice) map[string]interface{} { + fields := make(map[string]interface{}, len(value)) + for i, v := range value { + key := fmt.Sprintf("%s[%d]", prefix, i) + fields[key] = v + } + return fields +} + func (o *OpcUAInputClient) MetricForEvent(nodeIdx int, event *ua.EventFieldList) telegraf.Metric { node := o.EventNodeMetricMapping[nodeIdx] fields := make(map[string]interface{}, len(event.EventFields)) diff --git a/plugins/common/opcua/input/input_client_test.go b/plugins/common/opcua/input/input_client_test.go index 99fbd2b80..32cf6d5d7 100644 --- a/plugins/common/opcua/input/input_client_test.go +++ b/plugins/common/opcua/input/input_client_test.go @@ -799,6 +799,8 @@ func TestMetricForNode(t *testing.T) { testname string nmm []NodeMetricMapping v interface{} + isArray bool + dataType ua.TypeID time time.Time status ua.StatusCode expected telegraf.Metric @@ -815,14 +817,60 @@ func TestMetricForNode(t *testing.T) { MetricTags: map[string]string{"t1": "v1"}, }, }, - v: 16, - time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}), - status: ua.StatusOK, + v: 16, + isArray: false, + dataType: ua.TypeIDInt32, + time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}), + status: ua.StatusOK, expected: metric.New("testingmetric", map[string]string{"t1": "v1", "id": "ns=3;s=hi"}, map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)", "fn": 16}, time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})), }, + { + testname: "array-like metric build correctly", + nmm: []NodeMetricMapping{ + { + Tag: NodeSettings{ + FieldName: "fn", + }, + idStr: "ns=3;s=hi", + metricName: "testingmetric", + MetricTags: map[string]string{"t1": "v1"}, + }, + }, + v: []int32{16, 17}, + isArray: true, + dataType: ua.TypeIDInt32, + time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}), + status: ua.StatusOK, + expected: metric.New("testingmetric", + map[string]string{"t1": "v1", "id": "ns=3;s=hi"}, + map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)", "fn[0]": 16, "fn[1]": 17}, + time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})), + }, + { + testname: "nil does not panic", + nmm: []NodeMetricMapping{ + { + Tag: NodeSettings{ + FieldName: "fn", + }, + idStr: "ns=3;s=hi", + metricName: "testingmetric", + MetricTags: map[string]string{"t1": "v1"}, + }, + }, + v: nil, + isArray: false, + dataType: ua.TypeIDNull, + time: time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{}), + status: ua.StatusOK, + expected: metric.New("testingmetric", + map[string]string{"t1": "v1", "id": "ns=3;s=hi"}, + map[string]interface{}{"Quality": "The operation succeeded. StatusGood (0x0)"}, + time.Date(2022, 03, 17, 8, 55, 00, 00, &time.Location{})), + }, } for _, tt := range tests { @@ -831,6 +879,8 @@ func TestMetricForNode(t *testing.T) { o.LastReceivedData[0].SourceTime = tt.time o.LastReceivedData[0].Quality = tt.status o.LastReceivedData[0].Value = tt.v + o.LastReceivedData[0].DataType = tt.dataType + o.LastReceivedData[0].IsArray = tt.isArray actual := o.MetricForNode(0) require.Equal(t, tt.expected.Tags(), actual.Tags()) require.Equal(t, tt.expected.Fields(), actual.Fields()) diff --git a/plugins/inputs/opcua/README.md b/plugins/inputs/opcua/README.md index 5317664c5..404f6dbc0 100644 --- a/plugins/inputs/opcua/README.md +++ b/plugins/inputs/opcua/README.md @@ -2,8 +2,9 @@ The `opcua` plugin retrieves data from OPC UA Server devices. -Telegraf minimum version: Telegraf 1.16 -Plugin minimum tested version: 1.16 +⭐ Telegraf v1.16.0 +🏷️ network +💻 linux, windows ## Global configuration options @@ -202,6 +203,13 @@ produces a metric like this: opcua,id=ns\=3;s\=Temperature temp=79.0,Quality="OK (0x0)",DataType="Float" 1597820490000000000 ``` +If the value is an array, each element is unpacked into a field +using indexed keys. For example: + +```text +opcua,id=ns\=3;s\=Temperature temp[0]=79.0,temp[1]=38.9,Quality="OK (0x0)",DataType="Float" 1597820490000000000 +``` + ## Group Configuration Groups can set default values for the namespace, identifier type, and diff --git a/plugins/inputs/opcua_listener/README.md b/plugins/inputs/opcua_listener/README.md index c4cfe7238..afe74ca76 100644 --- a/plugins/inputs/opcua_listener/README.md +++ b/plugins/inputs/opcua_listener/README.md @@ -315,6 +315,13 @@ produces a metric like this: opcua,id=ns\=3;s\=Temperature temp=79.0,Quality="OK (0x0)",DataType="Float" 1597820490000000000 ``` +If the value is an array, each element is unpacked into a field +using indexed keys. For example: + +```text +opcua,id=ns\=3;s\=Temperature temp[0]=79.0,temp[1]=38.9,Quality="OK (0x0)",DataType="Float" 1597820490000000000 +``` + #### Group Configuration Groups can set default values for the namespace, identifier type, tags