@cryptotaxi247 / netdata-1 / commits / e47687ece

chore(go.d/ddsnmp): correctly handle all mapping types (#20420)

Ilya Mashchenko committed Jun 5, 2025 at 13:19 UTC e47687ece3669058691105ec1b5b44967eb7b718
5 files changed +400 -58
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collect_device_meta.go
+18 -13
@@ -21,11 +21,11 @@ func (c *Collector) collectDeviceMetadata(prof *ddsnmp.Profile) (map[string]stri
21 var tagOIDs []string
22 tags := make(map[string]string)
23
24 - for resName, metaCfg := range prof.Definition.Metadata {
24 + for resName, cfg := range prof.Definition.Metadata {
25 if !ddprofiledefinition.IsMetadataResourceWithScalarOids(resName) {
26 continue
27 }
28 - for name, field := range metaCfg.Fields {
28 + for name, field := range cfg.Fields {
29 switch {
30 case field.Value != "":
31 tags[name] = field.Value
@@ -55,11 +55,11 @@ func (c *Collector) collectDeviceMetadata(prof *ddsnmp.Profile) (map[string]stri
55
56 var errs []error
57
58 - for resName, metaCfg := range prof.Definition.Metadata {
58 + for resName, cfg := range prof.Definition.Metadata {
59 if !ddprofiledefinition.IsMetadataResourceWithScalarOids(resName) {
60 continue
61 }
62 - for name, field := range metaCfg.Fields {
62 + for name, field := range cfg.Fields {
63 switch {
64 case field.Symbol.OID != "":
65 v, err := processSymbolTagValue(field.Symbol, pdus)
@@ -88,26 +88,31 @@ func (c *Collector) collectDeviceMetadata(prof *ddsnmp.Profile) (map[string]stri
88 return tags, nil
89 }
90
91 -func processSymbolTagValue(symCfg ddprofiledefinition.SymbolConfig, result map[string]gosnmp.SnmpPDU) (string, error) {
92 - pdu, ok := result[trimOID(symCfg.OID)]
91 +func processSymbolTagValue(cfg ddprofiledefinition.SymbolConfig, result map[string]gosnmp.SnmpPDU) (string, error) {
92 + pdu, ok := result[trimOID(cfg.OID)]
93 if !ok {
94 return "", nil
95 }
96
97 - val, err := convPduToStringf(pdu, symCfg.Format)
97 + val, err := convPduToStringf(pdu, cfg.Format)
98 if err != nil {
99 return "", err
100 }
101
102 switch {
103 - case symCfg.ExtractValueCompiled != nil:
104 - if sm := symCfg.ExtractValueCompiled.FindStringSubmatch(val); len(sm) > 1 {
105 - return sm[1], nil
103 + case cfg.ExtractValueCompiled != nil:
104 + if sm := cfg.ExtractValueCompiled.FindStringSubmatch(val); len(sm) > 1 {
105 + val = sm[1]
106 }
107 - case symCfg.MatchPatternCompiled != nil:
108 - if sm := symCfg.MatchPatternCompiled.FindStringSubmatch(val); len(sm) > 0 {
109 - return replaceSubmatches(symCfg.MatchValue, sm), nil
107 + case cfg.MatchPatternCompiled != nil:
108 + if sm := cfg.MatchPatternCompiled.FindStringSubmatch(val); len(sm) > 0 {
109 + val = replaceSubmatches(cfg.MatchValue, sm)
110 }
111 }
112 +
113 + if v, ok := cfg.Mapping[val]; ok {
114 + val = v
115 + }
116 +
117 return val, nil
118 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collect_scalar.go
+36 -2
@@ -67,7 +67,7 @@ func (c *Collector) collectScalarMetric(cfg ddprofiledefinition.MetricsConfig, p
67
68 value, err := processSymbolValue(cfg.Symbol, pdu)
69 if err != nil {
70 - return nil, fmt.Errorf("error processing value: %w", err)
70 + return nil, fmt.Errorf("error processing value for OID %s (%s): %w", cfg.Symbol.Name, cfg.Symbol.OID, err)
71 }
72
73 tags := make(map[string]string)
@@ -78,12 +78,31 @@ func (c *Collector) collectScalarMetric(cfg ddprofiledefinition.MetricsConfig, p
78 }
79 }
80
81 + var mappings map[int64]string
82 +
83 + if len(cfg.Symbol.Mapping) > 0 {
84 + mappings = make(map[int64]string)
85 + if isMappingKeysNumeric(cfg.Symbol.Mapping) {
86 + for k, v := range cfg.Symbol.Mapping {
87 + intKey, _ := strconv.ParseInt(k, 10, 64)
88 + mappings[intKey] = v
89 + }
90 + } else {
91 + for k, v := range cfg.Symbol.Mapping {
92 + if intVal, err := strconv.ParseInt(v, 10, 64); err == nil {
93 + mappings[intVal] = k
94 + }
95 + }
96 + }
97 + }
98 +
99 return &Metric{
100 Name: cfg.Symbol.Name,
101 Value: value,
102 Tags: tags,
103 Unit: cfg.Symbol.Unit,
104 Description: cfg.Symbol.Description,
105 + Mappings: mappings,
106 MetricType: string(getMetricType(cfg.Symbol, pdu)),
107 }, nil
108 }
@@ -93,11 +112,18 @@ func processSymbolValue(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU
112
113 if isPduNumericType(pdu) {
114 value = gosnmp.ToBigInt(pdu.Value).Int64()
115 + if len(sym.Mapping) > 0 {
116 + s := strconv.FormatInt(value, 10)
117 + if v, ok := sym.Mapping[s]; ok && isInt(v) {
118 + value, _ = strconv.ParseInt(v, 10, 64)
119 + }
120 + }
121 } else {
122 s, err := convPduToStringf(pdu, sym.Format)
123 if err != nil {
124 return 0, err
125 }
126 +
127 switch {
128 case sym.ExtractValueCompiled != nil:
129 if sm := sym.ExtractValueCompiled.FindStringSubmatch(s); len(sm) > 1 {
@@ -108,7 +134,15 @@ func processSymbolValue(sym ddprofiledefinition.SymbolConfig, pdu gosnmp.SnmpPDU
134 s = replaceSubmatches(sym.MatchValue, sm)
135 }
136 }
111 - if v, ok := sym.Mapping[s]; ok {
137 +
138 + // Handle mapping based on the mapping type:
139 + // 1. Int -> String mapping (e.g., {"1": "up", "2": "down"}):
140 + // - Used for creating dimensions later
141 + // - Value remains numeric, no conversion needed here
142 + // 2. String -> Int mapping (e.g., {"OK": "0", "WARNING": "1"}):
143 + // - Used to convert string values to numeric values
144 + // - Apply the mapping to get the numeric representation
145 + if v, ok := sym.Mapping[s]; ok && isInt(v) {
146 s = v
147 }
148
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+2 -2
@@ -27,7 +27,7 @@ type Metric struct {
27 Unit string
28 MetricType string
29 Tags map[string]string
30 - Mappings map[string]string
30 + Mappings map[int64]string
31 Value int64
32 }
33
@@ -77,7 +77,7 @@ func (c *Collector) Collect() ([]*ProfileMetrics, error) {
77 return nil, errors.Join(errs...)
78 }
79 if len(errs) > 0 {
80 - c.log.Debugf("collecting metrics: %v", errs)
80 + c.log.Debugf("collecting metrics: %v", errors.Join(errs...))
81 }
82
83 return metrics, nil
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go
+330 -41
@@ -10,6 +10,7 @@ import (
10 "github.com/golang/mock/gomock"
11 "github.com/gosnmp/gosnmp"
12 "github.com/stretchr/testify/assert"
13 + "github.com/stretchr/testify/require"
14
15 snmpmock "github.com/gosnmp/gosnmp/mocks"
16
@@ -448,7 +449,98 @@ func TestCollector_Collect(t *testing.T) {
449 },
450 expectedError: false,
451 },
451 - "metric with mapping": {
452 + "global tags with mapping": {
453 + profiles: []*ddsnmp.Profile{
454 + {
455 + SourceFile: "test-profile.yaml",
456 + Definition: &ddprofiledefinition.ProfileDefinition{
457 + MetricTags: []ddprofiledefinition.MetricTagConfig{
458 + {
459 + Tag: "device_type",
460 + Symbol: ddprofiledefinition.SymbolConfigCompat{
461 + OID: "1.3.6.1.2.1.1.2.0",
462 + Name: "sysObjectID",
463 + },
464 + Mapping: map[string]string{
465 + "1.3.6.1.4.1.9.1.1": "router",
466 + "1.3.6.1.4.1.9.1.2": "switch",
467 + },
468 + },
469 + },
470 + Metrics: []ddprofiledefinition.MetricsConfig{
471 + {
472 + Symbol: ddprofiledefinition.SymbolConfig{
473 + OID: "1.3.6.1.2.1.1.3.0",
474 + Name: "sysUpTime",
475 + },
476 + },
477 + },
478 + },
479 + },
480 + },
481 + setupMock: func(m *snmpmock.MockHandler) {
482 + m.EXPECT().MaxOids().Return(10).AnyTimes()
483 + // First call for global tags
484 + m.EXPECT().Get([]string{"1.3.6.1.2.1.1.2.0"}).Return(
485 + &gosnmp.SnmpPacket{
486 + Variables: []gosnmp.SnmpPDU{
487 + {
488 + Name: "1.3.6.1.2.1.1.2.0",
489 + Type: gosnmp.ObjectIdentifier,
490 + Value: "1.3.6.1.4.1.9.1.1",
491 + },
492 + },
493 + }, nil,
494 + )
495 + // Second call for metrics
496 + m.EXPECT().Get([]string{"1.3.6.1.2.1.1.3.0"}).Return(
497 + &gosnmp.SnmpPacket{
498 + Variables: []gosnmp.SnmpPDU{
499 + {
500 + Name: "1.3.6.1.2.1.1.3.0",
501 + Type: gosnmp.TimeTicks,
502 + Value: uint32(123456),
503 + },
504 + },
505 + }, nil,
506 + )
507 + },
508 + expectedResult: []*ProfileMetrics{
509 + {
510 + DeviceMetadata: nil,
511 + Metrics: []Metric{
512 + {
513 + Name: "sysUpTime",
514 + Value: 123456,
515 + Tags: map[string]string{"device_type": "router"},
516 + MetricType: "gauge",
517 + },
518 + },
519 + },
520 + },
521 + expectedError: false,
522 + },
523 + "empty profile - no metrics defined": {
524 + profiles: []*ddsnmp.Profile{
525 + {
526 + SourceFile: "empty-profile.yaml",
527 + Definition: &ddprofiledefinition.ProfileDefinition{
528 + Metrics: []ddprofiledefinition.MetricsConfig{},
529 + },
530 + },
531 + },
532 + setupMock: func(m *snmpmock.MockHandler) {
533 + m.EXPECT().MaxOids().Return(10).AnyTimes()
534 + },
535 + expectedResult: []*ProfileMetrics{
536 + {
537 + DeviceMetadata: nil,
538 + Metrics: []Metric{},
539 + },
540 + },
541 + expectedError: false,
542 + },
543 + "metric with string to int mapping": {
544 profiles: []*ddsnmp.Profile{
545 {
546 SourceFile: "test-profile.yaml",
@@ -492,35 +584,92 @@ func TestCollector_Collect(t *testing.T) {
584 Value: 1,
585 Tags: map[string]string{},
586 MetricType: "gauge",
587 + Mappings: map[int64]string{
588 + 0: "OK",
589 + 1: "WARNING",
590 + 2: "CRITICAL",
591 + },
592 },
593 },
594 },
595 },
596 expectedError: false,
597 },
501 - "global tags with mapping": {
598 + "metric with int to string mapping": {
599 profiles: []*ddsnmp.Profile{
600 {
601 SourceFile: "test-profile.yaml",
602 Definition: &ddprofiledefinition.ProfileDefinition{
506 - MetricTags: []ddprofiledefinition.MetricTagConfig{
603 + Metrics: []ddprofiledefinition.MetricsConfig{
604 {
508 - Tag: "device_type",
509 - Symbol: ddprofiledefinition.SymbolConfigCompat{
510 - OID: "1.3.6.1.2.1.1.2.0",
511 - Name: "sysObjectID",
512 - },
513 - Mapping: map[string]string{
514 - "1.3.6.1.4.1.9.1.1": "router",
515 - "1.3.6.1.4.1.9.1.2": "switch",
605 + Symbol: ddprofiledefinition.SymbolConfig{
606 + OID: "1.3.6.1.2.1.2.2.1.8",
607 + Name: "ifOperStatus",
608 + Mapping: map[string]string{
609 + "1": "up",
610 + "2": "down",
611 + "3": "testing",
612 + "4": "unknown",
613 + "5": "dormant",
614 + },
615 },
616 },
617 },
618 + },
619 + },
620 + },
621 + setupMock: func(m *snmpmock.MockHandler) {
622 + m.EXPECT().MaxOids().Return(10).AnyTimes()
623 + m.EXPECT().Get([]string{"1.3.6.1.2.1.2.2.1.8"}).Return(
624 + &gosnmp.SnmpPacket{
625 + Variables: []gosnmp.SnmpPDU{
626 + {
627 + Name: "1.3.6.1.2.1.2.2.1.8",
628 + Type: gosnmp.Integer,
629 + Value: 2, // down
630 + },
631 + },
632 + }, nil,
633 + )
634 + },
635 + expectedResult: []*ProfileMetrics{
636 + {
637 + DeviceMetadata: nil,
638 + Metrics: []Metric{
639 + {
640 + Name: "ifOperStatus",
641 + Value: 2,
642 + Tags: map[string]string{},
643 + MetricType: "gauge",
644 + Mappings: map[int64]string{
645 + 1: "up",
646 + 2: "down",
647 + 3: "testing",
648 + 4: "unknown",
649 + 5: "dormant",
650 + },
651 + },
652 + },
653 + },
654 + },
655 + expectedError: false,
656 + },
657 + "metric with extract_value and int to string mapping": {
658 + profiles: []*ddsnmp.Profile{
659 + {
660 + SourceFile: "test-profile.yaml",
661 + Definition: &ddprofiledefinition.ProfileDefinition{
662 Metrics: []ddprofiledefinition.MetricsConfig{
663 {
664 Symbol: ddprofiledefinition.SymbolConfig{
522 - OID: "1.3.6.1.2.1.1.3.0",
523 - Name: "sysUpTime",
665 + OID: "1.3.6.1.4.1.12124.1.1.8",
666 + Name: "fanStatus",
667 + ExtractValueCompiled: mustCompileRegex(`Fan(\d+)`),
668 + Mapping: map[string]string{
669 + "1": "normal",
670 + "2": "warning",
671 + "3": "critical",
672 + },
673 },
674 },
675 },
@@ -529,26 +678,68 @@ func TestCollector_Collect(t *testing.T) {
678 },
679 setupMock: func(m *snmpmock.MockHandler) {
680 m.EXPECT().MaxOids().Return(10).AnyTimes()
532 - // First call for global tags
533 - m.EXPECT().Get([]string{"1.3.6.1.2.1.1.2.0"}).Return(
681 + m.EXPECT().Get([]string{"1.3.6.1.4.1.12124.1.1.8"}).Return(
682 &gosnmp.SnmpPacket{
683 Variables: []gosnmp.SnmpPDU{
684 {
537 - Name: "1.3.6.1.2.1.1.2.0",
538 - Type: gosnmp.ObjectIdentifier,
539 - Value: "1.3.6.1.4.1.9.1.1",
685 + Name: "1.3.6.1.4.1.12124.1.1.8",
686 + Type: gosnmp.OctetString,
687 + Value: []byte("Fan2"),
688 },
689 },
690 }, nil,
691 )
544 - // Second call for metrics
545 - m.EXPECT().Get([]string{"1.3.6.1.2.1.1.3.0"}).Return(
692 + },
693 + expectedResult: []*ProfileMetrics{
694 + {
695 + DeviceMetadata: nil,
696 + Metrics: []Metric{
697 + {
698 + Name: "fanStatus",
699 + Value: 2,
700 + Tags: map[string]string{},
701 + MetricType: "gauge",
702 + Mappings: map[int64]string{
703 + 1: "normal",
704 + 2: "warning",
705 + 3: "critical",
706 + },
707 + },
708 + },
709 + },
710 + },
711 + expectedError: false,
712 + },
713 + "metric with int to int mapping": {
714 + profiles: []*ddsnmp.Profile{
715 + {
716 + SourceFile: "test-profile.yaml",
717 + Definition: &ddprofiledefinition.ProfileDefinition{
718 + Metrics: []ddprofiledefinition.MetricsConfig{
719 + {
720 + Symbol: ddprofiledefinition.SymbolConfig{
721 + OID: "1.3.6.1.2.1.2.2.1.7",
722 + Name: "ifAdminStatus",
723 + Mapping: map[string]string{
724 + "1": "1", // up -> 1
725 + "2": "0", // down -> 0
726 + "3": "0", // testing -> 0
727 + },
728 + },
729 + },
730 + },
731 + },
732 + },
733 + },
734 + setupMock: func(m *snmpmock.MockHandler) {
735 + m.EXPECT().MaxOids().Return(10).AnyTimes()
736 + m.EXPECT().Get([]string{"1.3.6.1.2.1.2.2.1.7"}).Return(
737 &gosnmp.SnmpPacket{
738 Variables: []gosnmp.SnmpPDU{
739 {
549 - Name: "1.3.6.1.2.1.1.3.0",
550 - Type: gosnmp.TimeTicks,
551 - Value: uint32(123456),
740 + Name: "1.3.6.1.2.1.2.2.1.7",
741 + Type: gosnmp.Integer,
742 + Value: 2, // down
743 },
744 },
745 }, nil,
@@ -559,32 +750,139 @@ func TestCollector_Collect(t *testing.T) {
750 DeviceMetadata: nil,
751 Metrics: []Metric{
752 {
562 - Name: "sysUpTime",
563 - Value: 123456,
564 - Tags: map[string]string{"device_type": "router"},
753 + Name: "ifAdminStatus",
754 + Value: 0, // mapped from 2 -> 0
755 + Tags: map[string]string{},
756 MetricType: "gauge",
757 + Mappings: map[int64]string{
758 + 1: "1",
759 + 2: "0",
760 + 3: "0",
761 + },
762 },
763 },
764 },
765 },
766 expectedError: false,
767 },
572 - "empty profile - no metrics defined": {
768 + "metric with partial string to int mapping": {
769 profiles: []*ddsnmp.Profile{
770 {
575 - SourceFile: "empty-profile.yaml",
771 + SourceFile: "test-profile.yaml",
772 Definition: &ddprofiledefinition.ProfileDefinition{
577 - Metrics: []ddprofiledefinition.MetricsConfig{},
773 + Metrics: []ddprofiledefinition.MetricsConfig{
774 + {
775 + Symbol: ddprofiledefinition.SymbolConfig{
776 + OID: "1.3.6.1.4.1.12124.1.1.2",
777 + Name: "clusterHealth",
778 + Mapping: map[string]string{
779 + "OK": "0",
780 + "WARNING": "1",
781 + // CRITICAL is not mapped
782 + },
783 + },
784 + },
785 + },
786 },
787 },
788 },
789 setupMock: func(m *snmpmock.MockHandler) {
790 m.EXPECT().MaxOids().Return(10).AnyTimes()
791 + m.EXPECT().Get([]string{"1.3.6.1.4.1.12124.1.1.2"}).Return(
792 + &gosnmp.SnmpPacket{
793 + Variables: []gosnmp.SnmpPDU{
794 + {
795 + Name: "1.3.6.1.4.1.12124.1.1.2",
796 + Type: gosnmp.OctetString,
797 + Value: []byte("CRITICAL"),
798 + },
799 + },
800 + }, nil,
801 + )
802 + },
803 + expectedResult: nil,
804 + expectedError: true,
805 + },
806 + "metric with mixed mapping values": {
807 + profiles: []*ddsnmp.Profile{
808 + {
809 + SourceFile: "test-profile.yaml",
810 + Definition: &ddprofiledefinition.ProfileDefinition{
811 + Metrics: []ddprofiledefinition.MetricsConfig{
812 + {
813 + Symbol: ddprofiledefinition.SymbolConfig{
814 + OID: "1.3.6.1.4.1.12124.1.1.2",
815 + Name: "deviceStatus",
816 + Mapping: map[string]string{
817 + "OK": "0",
818 + "WARNING": "1",
819 + "ERROR": "invalid", // This will cause metric to be skipped
820 + },
821 + },
822 + },
823 + },
824 + },
825 + },
826 + },
827 + setupMock: func(m *snmpmock.MockHandler) {
828 + m.EXPECT().MaxOids().Return(10).AnyTimes()
829 + m.EXPECT().Get([]string{"1.3.6.1.4.1.12124.1.1.2"}).Return(
830 + &gosnmp.SnmpPacket{
831 + Variables: []gosnmp.SnmpPDU{
832 + {
833 + Name: "1.3.6.1.4.1.12124.1.1.2",
834 + Type: gosnmp.OctetString,
835 + Value: []byte("ERROR"),
836 + },
837 + },
838 + }, nil,
839 + )
840 + },
841 + expectedResult: nil,
842 + expectedError: true,
843 + },
844 + "metric with no mapping": {
845 + profiles: []*ddsnmp.Profile{
846 + {
847 + SourceFile: "test-profile.yaml",
848 + Definition: &ddprofiledefinition.ProfileDefinition{
849 + Metrics: []ddprofiledefinition.MetricsConfig{
850 + {
851 + Symbol: ddprofiledefinition.SymbolConfig{
852 + OID: "1.3.6.1.2.1.1.3.0",
853 + Name: "sysUpTime",
854 + },
855 + },
856 + },
857 + },
858 + },
859 + },
860 + setupMock: func(m *snmpmock.MockHandler) {
861 + m.EXPECT().MaxOids().Return(10).AnyTimes()
862 + m.EXPECT().Get([]string{"1.3.6.1.2.1.1.3.0"}).Return(
863 + &gosnmp.SnmpPacket{
864 + Variables: []gosnmp.SnmpPDU{
865 + {
866 + Name: "1.3.6.1.2.1.1.3.0",
867 + Type: gosnmp.TimeTicks,
868 + Value: uint32(123456),
869 + },
870 + },
871 + }, nil,
872 + )
873 },
874 expectedResult: []*ProfileMetrics{
875 {
876 DeviceMetadata: nil,
587 - Metrics: []Metric{},
877 + Metrics: []Metric{
878 + {
879 + Name: "sysUpTime",
880 + Value: 123456,
881 + Tags: map[string]string{},
882 + MetricType: "gauge",
883 + Mappings: nil, // No mappings
884 + },
885 + },
886 },
887 },
888 expectedError: false,
@@ -593,24 +891,16 @@ func TestCollector_Collect(t *testing.T) {
891
892 for name, tc := range tests {
893 t.Run(name, func(t *testing.T) {
596 - // Create gomock controller
894 ctrl := gomock.NewController(t)
895 defer ctrl.Finish()
896
600 - // Create mock SNMP client
897 mockHandler := snmpmock.NewMockHandler(ctrl)
898 tc.setupMock(mockHandler)
899
604 - // Create logger
605 - log := logger.New()
606 -
607 - // Create collector
608 - collector := New(mockHandler, tc.profiles, log)
900 + collector := New(mockHandler, tc.profiles, logger.New())
901
610 - // Execute
902 result, err := collector.Collect()
903
613 - // Verify error
904 if tc.expectedError {
905 assert.Error(t, err)
906 if tc.errorContains != "" {
@@ -620,9 +910,8 @@ func TestCollector_Collect(t *testing.T) {
910 assert.NoError(t, err)
911 }
912
623 - // Verify result
913 if tc.expectedResult != nil {
625 - assert.Equal(t, len(tc.expectedResult), len(result))
914 + require.Equal(t, len(tc.expectedResult), len(result))
915 for i := range tc.expectedResult {
916 assert.Equal(t, tc.expectedResult[i].DeviceMetadata, result[i].DeviceMetadata)
917 assert.ElementsMatch(t, tc.expectedResult[i].Metrics, result[i].Metrics)
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/utils.go
+14
@@ -214,3 +214,17 @@ func ternary[T any](cond bool, a, b T) T {
214 }
215 return b
216 }
217 +
218 +func isInt(s string) bool {
219 + _, err := strconv.ParseInt(s, 10, 64)
220 + return err == nil
221 +}
222 +
223 +func isMappingKeysNumeric(mapping map[string]string) bool {
224 + for k := range mapping {
225 + if !isInt(k) {
226 + return false
227 + }
228 + }
229 + return true
230 +}