Repository: influxdata/telegraf (2026-03-17T15:40:45Z)

Issue:
[outputs.cloudwatch] Telegraf reports only 10 dimensions to CloudWatch
### Relevant telegraf.conf

```toml
.
```


### Logs from Telegraf

```text
.
```


### System info

Telegraf 1.28.3+ (up to master that is 1.33.0 now)

### Docker

_No response_

### Steps to reproduce

.

### Expected behavior

According to CloudWatch documentation, it allows up to 30 dimensions (https://docs.aws.amazon.com.mcas.ms/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#usingDimensions),

### Actual behavior

CloudWatch output plugin has a hardcoded value of 10: https://github.com/influxdata/telegraf/blob/316f2d91fba98dd0bd9745bf35f6854a52a1572d/plugins/outputs/cloudwatch/cloudwatch.go#L317
Also, there is no information about tags trimming.

### Additional info

_No response_


Discussion:

Patch:
diff --git a/plugins/outputs/cloudwatch/README.md b/plugins/outputs/cloudwatch/README.md
index 3820779516f07..107fc0ef73783 100644
--- a/plugins/outputs/cloudwatch/README.md
+++ b/plugins/outputs/cloudwatch/README.md
@@ -59,18 +59,16 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details.
   ## 5) environment variables
   ## 6) shared credentials file
   ## 7) EC2 Instance Profile
-  #access_key = ""
-  #secret_key = ""
-  #token = ""
-  #role_arn = ""
-  #web_identity_token_file = ""
-  #role_session_name = ""
-  #profile = ""
-  #shared_credential_file = ""
-
-  ## Endpoint to make request against, the correct endpoint is automatically
-  ## determined and this option should only be set if you wish to override the
-  ## default.
+  # access_key = ""
+  # secret_key = ""
+  # token = ""
+  # role_arn = ""
+  # web_identity_token_file = ""
+  # role_session_name = ""
+  # profile = ""
+  # shared_credential_file = ""
+
+  ## Override the auto-detected endpoint to make request against
   ##   ex: endpoint_url = "http://localhost:8000"
   # endpoint_url = ""
 
@@ -93,6 +91,12 @@ plugin ordering. See [CONFIGURATION.md][CONFIGURATION.md] for more details.
   ## Enable high resolution metrics of 1 second (if not enabled, standard
   ## resolution are of 60 seconds precision)
   # high_resolution_metrics = false
+
+  ## Maximum number of dimensions to include in the metric
+  ## The default is ten for backward compatibility but Cloudwatch supports
+  ## up to 30 dimensions in a metric according to
+  ## https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#usingDimensions
+  # max_dimensions = 10
 ```
 
 For this output plugin to function correctly the following variables must be
diff --git a/plugins/outputs/cloudwatch/cloudwatch.go b/plugins/outputs/cloudwatch/cloudwatch.go
index ba067c75eaec4..d8717fb78a7a5 100644
--- a/plugins/outputs/cloudwatch/cloudwatch.go
+++ b/plugins/outputs/cloudwatch/cloudwatch.go
@@ -25,16 +25,19 @@ import (
 var sampleConfig string
 
 const (
-	// PutMetricData only supports up to 1000 data metrics per call
+	// Cloudwatch only supports up to 1000 data metrics per call according to
 	// https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_PutMetricData.html
-	maxBatchSize  = 1000
-	maxDimensions = 10
+	maxBatchSize = 1000
+	// Cloudwatch only accepts up to 30 dimensions per metric according to
+	// https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#usingDimensions
+	maxDimensions = 30
 )
 
 type CloudWatch struct {
 	Namespace             string          `toml:"namespace"` // CloudWatch Metrics Namespace
 	HighResolutionMetrics bool            `toml:"high_resolution_metrics"`
 	WriteStatistics       bool            `toml:"write_statistics"`
+	MaxDimensions         int             `toml:"max_dimensions"`
 	Log                   telegraf.Logger `toml:"-"`
 	common_aws.CredentialConfig
 	common_http.HTTPClientConfig
@@ -49,10 +52,15 @@ func (*CloudWatch) SampleConfig() string {
 }
 
 func (c *CloudWatch) Init() error {
+	// Check user settings
 	if c.Namespace == "" {
 		return errors.New("namespace is required")
 	}
 
+	if c.MaxDimensions < 0 || c.MaxDimensions > maxDimensions {
+		return fmt.Errorf("number of dimensions has to be between 0 and %d", maxDimensions)
+	}
+
 	// Determine the metric resolution
 	c.resolution = 60
 	if c.HighResolutionMetrics {
@@ -112,7 +120,8 @@ func (c *CloudWatch) Write(metrics []telegraf.Metric) error {
 }
 
 func (c *CloudWatch) buildMetricDatum(m telegraf.Metric) []types.MetricDatum {
-	tags := m.TagList()
+	// Extract the dimensions from tags
+	dimensions := c.buildDimensions(m.TagList())
 
 	// Aggregate the metric values into statistics if enabled
 	fields := make(map[string]cloudwatchField, len(m.FieldList()))
@@ -148,7 +157,7 @@ func (c *CloudWatch) buildMetricDatum(m telegraf.Metric) []types.MetricDatum {
 			fields[f.Key] = &valueField{
 				measurement: m.Name(),
 				name:        f.Key,
-				tags:        tags,
+				dimensions:  dimensions,
 				timestamp:   m.Time(),
 				value:       val,
 				resolution:  c.resolution,
@@ -158,7 +167,7 @@ func (c *CloudWatch) buildMetricDatum(m telegraf.Metric) []types.MetricDatum {
 			fields[fieldName] = &statisticField{
 				measurement: m.Name(),
 				name:        fieldName,
-				tags:        tags,
+				dimensions:  dimensions,
 				timestamp:   m.Time(),
 				values:      map[statisticType]float64{sType: val},
 				resolution:  c.resolution,
@@ -179,6 +188,42 @@ func (c *CloudWatch) buildMetricDatum(m telegraf.Metric) []types.MetricDatum {
 	return datums
 }
 
+func (c *CloudWatch) buildDimensions(tags []*telegraf.Tag) []types.Dimension {
+	dimensions := make([]types.Dimension, 0, c.MaxDimensions)
+	if c.MaxDimensions == 0 {
+		return dimensions
+	}
+
+	// Make sure we add the "host" tag if any
+	for _, t := range tags {
+		if t.Key != "host" {
+			continue
+		}
+		dimensions = append(dimensions, types.Dimension{
+			Name:  aws.String("host"),
+			Value: aws.String(t.Value),
+		})
+		break
+	}
+
+	// Add more tags until we reach the maximum
+	// NOTE: The tag-list is already sorted so no need to sort it again
+	for _, t := range tags {
+		if len(dimensions) >= c.MaxDimensions {
+			break
+		}
+		if t.Key == "host" || t.Value == "" {
+			continue
+		}
+		dimensions = append(dimensions, types.Dimension{
+			Name:  aws.String(t.Key),
+			Value: aws.String(t.Value),
+		})
+	}
+
+	return dimensions
+}
+
 func partitionDatums(datums []types.MetricDatum, batchSize int) [][]types.MetricDatum {
 	// Partition all given metrics into batches with the given batch size
 	numberOfPartitions := len(datums) / batchSize
@@ -242,6 +287,8 @@ func convert(v interface{}) (float64, bool) {
 
 func init() {
 	outputs.Add("cloudwatch", func() telegraf.Output {
-		return &CloudWatch{}
+		return &CloudWatch{
+			MaxDimensions: 10, // for backward compatibility
+		}
 	})
 }
diff --git a/plugins/outputs/cloudwatch/fields.go b/plugins/outputs/cloudwatch/fields.go
index a0894cfdd0aa0..53722061906cf 100644
--- a/plugins/outputs/cloudwatch/fields.go
+++ b/plugins/outputs/cloudwatch/fields.go
@@ -6,8 +6,6 @@ import (
 
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
-
-	"github.com/influxdata/telegraf"
 )
 
 type statisticType int
@@ -27,7 +25,7 @@ type cloudwatchField interface {
 
 type statisticField struct {
 	measurement string
-	tags        []*telegraf.Tag
+	dimensions  []types.Dimension
 	name        string
 	values      map[statisticType]float64
 	timestamp   time.Time
@@ -50,7 +48,7 @@ func (f *statisticField) buildDatum() []types.MetricDatum {
 
 		datum := types.MetricDatum{
 			MetricName: aws.String(strings.Join([]string{f.measurement, f.name}, "_")),
-			Dimensions: buildDimensions(f.tags),
+			Dimensions: f.dimensions,
 			Timestamp:  aws.Time(f.timestamp),
 			StatisticValues: &types.StatisticSet{
 				Minimum:     aws.Float64(vmin),
@@ -69,7 +67,7 @@ func (f *statisticField) buildDatum() []types.MetricDatum {
 	for sType, value := range f.values {
 		datum := types.MetricDatum{
 			Value:      aws.Float64(value),
-			Dimensions: buildDimensions(f.tags),
+			Dimensions: f.dimensions,
 			Timestamp:  aws.Time(f.timestamp),
 		}
 
@@ -104,7 +102,7 @@ func (f *statisticField) hasAllFields() bool {
 
 type valueField struct {
 	measurement string
-	tags        []*telegraf.Tag
+	dimensions  []types.Dimension
 	name        string
 	value       float64
 	timestamp   time.Time
@@ -122,45 +120,9 @@ func (f *valueField) buildDatum() []types.MetricDatum {
 		{
 			MetricName:        aws.String(strings.Join([]string{f.measurement, f.name}, "_")),
 			Value:             aws.Float64(f.value),
-			Dimensions:        buildDimensions(f.tags),
+			Dimensions:        f.dimensions,
 			Timestamp:         aws.Time(f.timestamp),
 			StorageResolution: aws.Int32(int32(f.resolution)),
 		},
 	}
 }
-
-// buildDimensions makes a list of Dimensions by using a Point's tags. CloudWatch supports up to
-// 10 dimensions per metric, so we only keep up to the first 10 alphabetically.
-// This always includes the "host" tag if it exists.
-func buildDimensions(tags []*telegraf.Tag) []types.Dimension {
-	dimensions := make([]types.Dimension, 0, maxDimensions)
-
-	// Make sure we add the "host" tag if any
-	for _, t := range tags {
-		if t.Key != "host" {
-			continue
-		}
-		dimensions = append(dimensions, types.Dimension{
-			Name:  aws.String("host"),
-			Value: aws.String(t.Value),
-		})
-		break
-	}
-
-	// Add more tags until we reach the maximum
-	// NOTE: The tag-list is already sorted so no need to sort it again
-	for _, t := range tags {
-		if len(dimensions) >= maxDimensions {
-			break
-		}
-		if t.Key == "host" || t.Value == "" {
-			continue
-		}
-		dimensions = append(dimensions, types.Dimension{
-			Name:  aws.String(t.Key),
-			Value: aws.String(t.Value),
-		})
-	}
-
-	return dimensions
-}
diff --git a/plugins/outputs/cloudwatch/sample.conf b/plugins/outputs/cloudwatch/sample.conf
index facbfbfc30f0e..49ce8949c7d77 100644
--- a/plugins/outputs/cloudwatch/sample.conf
+++ b/plugins/outputs/cloudwatch/sample.conf
@@ -13,18 +13,16 @@
   ## 5) environment variables
   ## 6) shared credentials file
   ## 7) EC2 Instance Profile
-  #access_key = ""
-  #secret_key = ""
-  #token = ""
-  #role_arn = ""
-  #web_identity_token_file = ""
-  #role_session_name = ""
-  #profile = ""
-  #shared_credential_file = ""
+  # access_key = ""
+  # secret_key = ""
+  # token = ""
+  # role_arn = ""
+  # web_identity_token_file = ""
+  # role_session_name = ""
+  # profile = ""
+  # shared_credential_file = ""
 
-  ## Endpoint to make request against, the correct endpoint is automatically
-  ## determined and this option should only be set if you wish to override the
-  ## default.
+  ## Override the auto-detected endpoint to make request against
   ##   ex: endpoint_url = "http://localhost:8000"
   # endpoint_url = ""
 
@@ -47,3 +45,9 @@
   ## Enable high resolution metrics of 1 second (if not enabled, standard
   ## resolution are of 60 seconds precision)
   # high_resolution_metrics = false
+
+  ## Maximum number of dimensions to include in the metric
+  ## The default is ten for backward compatibility but Cloudwatch supports
+  ## up to 30 dimensions in a metric according to
+  ## https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/publishingMetrics.html#usingDimensions
+  # max_dimensions = 10


Test patch:
diff --git a/plugins/outputs/cloudwatch/cloudwatch_test.go b/plugins/outputs/cloudwatch/cloudwatch_test.go
index c0238d05762e0..f1bbc705ee808 100644
--- a/plugins/outputs/cloudwatch/cloudwatch_test.go
+++ b/plugins/outputs/cloudwatch/cloudwatch_test.go
@@ -16,27 +16,6 @@ import (
 
 // Test that each tag becomes one dimension
 func TestBuildDimensions(t *testing.T) {
-	tests := []struct {
-		name     string
-		expected []types.Dimension
-	}{
-		{
-			name: "10 max dimensions",
-			expected: []types.Dimension{
-				{Name: aws.String("host"), Value: aws.String("localhost")},
-				{Name: aws.String("a"), Value: aws.String("1")},
-				{Name: aws.String("b"), Value: aws.String("2")},
-				{Name: aws.String("c"), Value: aws.String("3")},
-				{Name: aws.String("d"), Value: aws.String("4")},
-				{Name: aws.String("e"), Value: aws.String("5")},
-				{Name: aws.String("f"), Value: aws.String("6")},
-				{Name: aws.String("g"), Value: aws.String("7")},
-				{Name: aws.String("h"), Value: aws.String("8")},
-				{Name: aws.String("i"), Value: aws.String("9")},
-			},
-		},
-	}
-
 	// Define the input tags and the expected output
 	input := []*telegraf.Tag{
 		{Key: "a", Value: "1"},
@@ -55,10 +34,65 @@ func TestBuildDimensions(t *testing.T) {
 		{Key: "m", Value: "13"},
 	}
 
+	tests := []struct {
+		name          string
+		maxDimensions int
+		expected      []types.Dimension
+	}{
+		{
+			name:          "0 max dimensions",
+			maxDimensions: 0,
+		},
+		{
+			name:          "10 max dimensions",
+			maxDimensions: 10,
+			expected: []types.Dimension{
+				{Name: aws.String("host"), Value: aws.String("localhost")},
+				{Name: aws.String("a"), Value: aws.String("1")},
+				{Name: aws.String("b"), Value: aws.String("2")},
+				{Name: aws.String("c"), Value: aws.String("3")},
+				{Name: aws.String("d"), Value: aws.String("4")},
+				{Name: aws.String("e"), Value: aws.String("5")},
+				{Name: aws.String("f"), Value: aws.String("6")},
+				{Name: aws.String("g"), Value: aws.String("7")},
+				{Name: aws.String("h"), Value: aws.String("8")},
+				{Name: aws.String("i"), Value: aws.String("9")},
+			},
+		},
+		{
+			name:          "30 max dimensions",
+			maxDimensions: 30,
+			expected: []types.Dimension{
+				{Name: aws.String("host"), Value: aws.String("localhost")},
+				{Name: aws.String("a"), Value: aws.String("1")},
+				{Name: aws.String("b"), Value: aws.String("2")},
+				{Name: aws.String("c"), Value: aws.String("3")},
+				{Name: aws.String("d"), Value: aws.String("4")},
+				{Name: aws.String("e"), Value: aws.String("5")},
+				{Name: aws.String("f"), Value: aws.String("6")},
+				{Name: aws.String("g"), Value: aws.String("7")},
+				{Name: aws.String("h"), Value: aws.String("8")},
+				{Name: aws.String("i"), Value: aws.String("9")},
+				{Name: aws.String("j"), Value: aws.String("10")},
+				{Name: aws.String("k"), Value: aws.String("11")},
+				{Name: aws.String("l"), Value: aws.String("12")},
+				{Name: aws.String("m"), Value: aws.String("13")},
+			},
+		},
+	}
+
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			// Setup plugin
+			plugin := &CloudWatch{
+				Namespace:     "foo",
+				MaxDimensions: tt.maxDimensions,
+				Log:           testutil.Logger{},
+			}
+			require.NoError(t, plugin.Init())
+
 			// Build the dimensions and check
-			dimensions := buildDimensions(input)
+			dimensions := plugin.buildDimensions(input)
 			require.Len(t, dimensions, len(tt.expected))
 			for i, actual := range dimensions {
 				require.Equalf(t, *tt.expected[i].Name, *actual.Name, "mismatch for element %d", i)
@@ -207,6 +241,7 @@ func TestBuildMetricDatums(t *testing.T) {
 				Namespace:             "foo",
 				WriteStatistics:       tt.statistics,
 				HighResolutionMetrics: tt.highres,
+				MaxDimensions:         10,
 				Log:                   testutil.Logger{},
 			}
 			require.NoError(t, plugin.Init())
@@ -239,6 +274,7 @@ func TestBuildMetricDatumResolution(t *testing.T) {
 			plugin := &CloudWatch{
 				Namespace:             "foo",
 				HighResolutionMetrics: tt.highres,
+				MaxDimensions:         10,
 				Log:                   testutil.Logger{},
 			}
 			require.NoError(t, plugin.Init())
@@ -257,6 +293,7 @@ func TestBuildMetricDatumsSkipEmptyTags(t *testing.T) {
 	plugin := &CloudWatch{
 		Namespace:       "foo",
 		WriteStatistics: true,
+		MaxDimensions:   10,
 		Log:             testutil.Logger{},
 	}
 	require.NoError(t, plugin.Init())