diff --git a/pkg/clients/tagging/v1/client.go b/pkg/clients/tagging/v1/client.go index f0f6624f6..c66bb29c9 100644 --- a/pkg/clients/tagging/v1/client.go +++ b/pkg/clients/tagging/v1/client.go @@ -115,6 +115,7 @@ func (c client) GetResources(ctx context.Context, job model.DiscoveryJob, region Namespace: job.Namespace, Region: region, Tags: make([]model.Tag, 0, len(resourceTagMapping.Tags)), + Metadata: make(map[string]string), } for _, t := range resourceTagMapping.Tags { diff --git a/pkg/clients/tagging/v1/filters.go b/pkg/clients/tagging/v1/filters.go index b02820450..94932a3de 100644 --- a/pkg/clients/tagging/v1/filters.go +++ b/pkg/clients/tagging/v1/filters.go @@ -109,6 +109,7 @@ var ServiceFilters = map[string]ServiceFilter{ ARN: aws.StringValue(asg.AutoScalingGroupARN), Namespace: job.Namespace, Region: region, + Metadata: make(map[string]string), } for _, t := range asg.Tags { @@ -196,6 +197,7 @@ var ServiceFilters = map[string]ServiceFilter{ ARN: aws.StringValue(ec2Spot.SpotFleetRequestId), Namespace: job.Namespace, Region: region, + Metadata: make(map[string]string), } for _, t := range ec2Spot.Tags { @@ -229,6 +231,7 @@ var ServiceFilters = map[string]ServiceFilter{ ARN: aws.StringValue(ws.Arn), Namespace: job.Namespace, Region: region, + Metadata: make(map[string]string), } for key, value := range ws.Tags { @@ -262,6 +265,7 @@ var ServiceFilters = map[string]ServiceFilter{ ARN: fmt.Sprintf("%s/%s", *gwa.GatewayId, *gwa.GatewayName), Namespace: job.Namespace, Region: region, + Metadata: make(map[string]string), } tagsRequest := &storagegateway.ListTagsForResourceInput{ @@ -302,6 +306,7 @@ var ServiceFilters = map[string]ServiceFilter{ ARN: fmt.Sprintf("%s/%s", *tgwa.TransitGatewayId, *tgwa.TransitGatewayAttachmentId), Namespace: job.Namespace, Region: region, + Metadata: make(map[string]string), } for _, t := range tgwa.Tags { @@ -352,12 +357,13 @@ var ServiceFilters = map[string]ServiceFilter{ // these land in us-east-1 so any protected resource without a region should be added when the job // is for us-east-1 if protectedResource.Region == region || (protectedResource.Region == "" && region == "us-east-1") { - taggedResource := &model.TaggedResource{ - ARN: protectedResourceArn, - Namespace: job.Namespace, - Region: region, - Tags: []model.Tag{{Key: "ProtectionArn", Value: protectionArn}}, - } + taggedResource := &model.TaggedResource{ + ARN: protectedResourceArn, + Namespace: job.Namespace, + Region: region, + Tags: []model.Tag{{Key: "ProtectionArn", Value: protectionArn}}, + Metadata: make(map[string]string), + } output = append(output, taggedResource) } } @@ -369,4 +375,5 @@ var ServiceFilters = map[string]ServiceFilter{ return output, nil }, }, + } diff --git a/pkg/clients/tagging/v2/client.go b/pkg/clients/tagging/v2/client.go index 5f0d704ef..5f07781d0 100644 --- a/pkg/clients/tagging/v2/client.go +++ b/pkg/clients/tagging/v2/client.go @@ -122,6 +122,7 @@ func (c client) GetResources(ctx context.Context, job model.DiscoveryJob, region Namespace: job.Namespace, Region: region, Tags: make([]model.Tag, 0, len(resourceTagMapping.Tags)), + Metadata: make(map[string]string), } for _, t := range resourceTagMapping.Tags { diff --git a/pkg/clients/tagging/v2/filters.go b/pkg/clients/tagging/v2/filters.go index 8be43eb62..12c7f0204 100644 --- a/pkg/clients/tagging/v2/filters.go +++ b/pkg/clients/tagging/v2/filters.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/amp" "github.com/aws/aws-sdk-go-v2/service/apigateway" "github.com/aws/aws-sdk-go-v2/service/apigatewayv2" @@ -382,6 +383,7 @@ var ServiceFilters = map[string]ServiceFilter{ Namespace: job.Namespace, Region: region, Tags: []model.Tag{{Key: "ProtectionArn", Value: protectionArn}}, + Metadata: make(map[string]string), } output = append(output, taggedResource) } @@ -391,4 +393,40 @@ var ServiceFilters = map[string]ServiceFilter{ return output, nil }, }, + "AWS/DynamoDB": { + // Fetch service-specific metadata for DynamoDB tables + FilterFunc: func(ctx context.Context, client client, inputResources []*model.TaggedResource) ([]*model.TaggedResource, error) { + // Create DynamoDB client + dynamoClient := dynamodb.NewFromConfig(client.config) + + // Process each DynamoDB table resource + for _, resource := range inputResources { + // Extract table name from ARN: arn:aws:dynamodb:region:account:table/TableName + arnParts := strings.Split(resource.ARN, "/") + if len(arnParts) < 2 { + continue + } + tableName := arnParts[len(arnParts)-1] + + // Fetch table description to get service-specific metadata + tableResult, err := dynamoClient.DescribeTable(ctx, &dynamodb.DescribeTableInput{ + TableName: &tableName, + }) + if err != nil { + // Log warning but don't fail the entire operation + continue + } + + // Extract and store service-specific metadata + if tableResult.Table != nil { + // Extract Table Class if available + if tableResult.Table.TableClassSummary != nil { + resource.Metadata["table_class"] = string(tableResult.Table.TableClassSummary.TableClass) + } + } + } + + return inputResources, nil + }, + }, } diff --git a/pkg/model/model.go b/pkg/model/model.go index deb2dbd53..6f31fa5a6 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -219,6 +219,10 @@ type TaggedResource struct { // Tags is a set of tags associated to the resource Tags []Tag + + // Metadata contains service-specific additional information about the resource + // For example: TableClass, BillingMode, InstanceType, State, etc. + Metadata map[string]string } // FilterThroughTags returns true if all filterTags match diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 4f75ca4e9..50ac1f152 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -177,6 +177,7 @@ func Test_FilterThroughTags(t *testing.T) { Namespace: "AWS/Service", Region: "us-east-1", Tags: tc.resourceTags, + Metadata: make(map[string]string), } require.Equal(t, tc.result, res.FilterThroughTags(tc.filterTags)) }) @@ -257,6 +258,7 @@ func Test_MetricTags(t *testing.T) { Namespace: "AWS/Service", Region: "us-east-1", Tags: tc.resourceTags, + Metadata: make(map[string]string), } require.Equal(t, tc.result, res.MetricTags(tc.exportedTags)) diff --git a/pkg/promutil/migrate.go b/pkg/promutil/migrate.go index 28a5c60ab..4a9f04d1f 100644 --- a/pkg/promutil/migrate.go +++ b/pkg/promutil/migrate.go @@ -68,30 +68,65 @@ func BuildMetricName(namespace, metricName, statistic string) string { } func BuildNamespaceInfoMetrics(tagData []model.TaggedResourceResult, metrics []*PrometheusMetric, observedMetricLabels map[string]model.LabelSet, labelsSnakeCase bool, logger *slog.Logger) ([]*PrometheusMetric, map[string]model.LabelSet) { + // Loop through each AWS service result (e.g., discovery results from any service) for _, tagResult := range tagData { + // Extract context labels (region, account_id, account_alias, custom_tags) from the scrape context contextLabels := contextToLabels(tagResult.Context, labelsSnakeCase, logger) + + // Loop through each discovered resource (e.g., each table, instance, bucket, etc.) for _, d := range tagResult.Data { + // Build the metric name: AWS/ServiceName + "info" + "" → "aws_servicename_info" metricName := BuildMetricName(d.Namespace, "info", "") - promLabels := make(map[string]string, len(d.Tags)+len(contextLabels)+1) + // Pre-allocate map for all labels: resource tags + context labels + metadata + 1 for "name" label + promLabels := make(map[string]string, len(d.Tags)+len(contextLabels)+len(d.Metadata)+1) + + // Copy all context labels (region, account_id, etc.) into the prometheus labels map maps.Copy(promLabels, contextLabels) + + // Add the "name" label containing the full ARN of the resource + // Example: "arn:aws:service:region:account:resource/ResourceName" promLabels["name"] = d.ARN + + // Loop through all AWS tags attached to this resource for _, tag := range d.Tags { + // Convert AWS tag key to valid Prometheus label name (handles special chars, snake_case) ok, promTag := PromStringTag(tag.Key, labelsSnakeCase) if !ok { + // Skip invalid tag names that can't be converted to Prometheus labels logger.Warn("tag name is an invalid prometheus label name", "tag", tag.Key) continue } + // Create label with "tag_" prefix: "Environment" → "tag_Environment" labelName := "tag_" + promTag + // Set the label value to the AWS tag value promLabels[labelName] = tag.Value } + // Loop through all metadata attached to this resource (e.g., service-specific attributes) + for metadataKey, metadataValue := range d.Metadata { + // Convert metadata key to valid Prometheus label name (handles special chars, snake_case) + ok, promKey := PromStringTag(metadataKey, labelsSnakeCase) + if !ok { + // Skip invalid metadata keys that can't be converted to Prometheus labels + logger.Warn("metadata key is an invalid prometheus label name", "key", metadataKey) + continue + } + + // Add metadata as labels directly (no prefix needed since they're service-specific) + // Examples: table_class="Standard", instance_type="t3.micro", billing_mode="PAY_PER_REQUEST" + promLabels[promKey] = metadataValue + } + + // Track all label names used by this metric for consistency checking later observedMetricLabels = recordLabelsForMetric(metricName, promLabels, observedMetricLabels) + + // Create the final info metric with all labels and value=0 (info metrics are label carriers) metrics = append(metrics, &PrometheusMetric{ - Name: metricName, - Labels: promLabels, - Value: 0, + Name: metricName, // e.g., "aws_dynamodb_info", "aws_ec2_info", etc. + Labels: promLabels, // All the labels we built above + Value: 0, // Info metrics always have value 0 }) } }