Skip to content

Commit 43f2526

Browse files
authored
Add resource_attributes support to opentelemetry section (#2207)
1 parent e6b40ae commit 43f2526

18 files changed

Lines changed: 413 additions & 8 deletions

translator/cmdutil/translatorutil_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,13 @@ func TestOpenTelemetryOtlpSchemaValidation(t *testing.T) {
431431
checkIfSchemaValidateAsExpected(t, "../../translator/config/sampleSchema/opentelemetry/validOpenTelemetryOtlp.json", true, map[string]int{})
432432
}
433433

434+
func TestOpenTelemetryResourceAttributesSchemaValidation(t *testing.T) {
435+
checkIfSchemaValidateAsExpected(t, "../../translator/config/sampleSchema/opentelemetry/validOpenTelemetryResourceAttributes.json", true, map[string]int{})
436+
checkIfSchemaValidateAsExpected(t, "../../translator/config/sampleSchema/opentelemetry/invalidOpenTelemetryResourceAttributes.json", false, map[string]int{
437+
"invalid_type": 1,
438+
})
439+
}
440+
434441
func TestDefaultOtelConfigSchemaValidation(t *testing.T) {
435442
cfg, ok := config.DefaultJSONConfigFor("otel")
436443
require.True(t, ok)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"agent": {
3+
"region": "us-west-2"
4+
},
5+
"opentelemetry": {
6+
"resource_attributes": {
7+
"team": 123
8+
},
9+
"collect": {
10+
"otlp": {
11+
"grpc_endpoint": "127.0.0.1:4317"
12+
}
13+
}
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"agent": {
3+
"region": "us-west-2"
4+
},
5+
"opentelemetry": {
6+
"resource_attributes": {
7+
"team": "cloudwatch",
8+
"environment": "prod"
9+
},
10+
"collect": {
11+
"otlp": {
12+
"grpc_endpoint": "127.0.0.1:4317",
13+
"http_endpoint": "127.0.0.1:4318"
14+
}
15+
}
16+
}
17+
}

translator/config/schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,16 @@
18101810
"description": "The name of the K8s cluster. Applied to all telemetry collected under opentelemetry.",
18111811
"type": "string"
18121812
},
1813+
"resource_attributes": {
1814+
"type": "object",
1815+
"description": "Additional resource attributes to add to every metric, log, and trace exported by the opentelemetry section. Applied via a resource processor at the front of each export pipeline. Attributes are added before resource detection, so for keys the agent auto-detects (e.g. cloud.region, host.id, cloud.provider) the detected value takes precedence over the configured one; use this for attributes the agent does not detect. Keys managed internally for log routing (aws.log.group.name, aws.log.stream.name, aws.log.source) are reserved and rejected.",
1816+
"maxProperties": 30,
1817+
"additionalProperties": {
1818+
"type": "string",
1819+
"minLength": 1,
1820+
"maxLength": 1024
1821+
}
1822+
},
18131823
"collect": {
18141824
"type": "object",
18151825
"properties": {

translator/tocwconfig/sampleConfig/opentelemetry/otlp_otel_config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
{
22
"agent": {"region": "us-west-2"},
33
"opentelemetry": {
4+
"resource_attributes": {
5+
"team": "cloudwatch",
6+
"deployment.environment": "prod"
7+
},
48
"collect": {
59
"otlp": {
610
"grpc_endpoint": "127.0.0.1:4317",

translator/tocwconfig/sampleConfig/opentelemetry/otlp_otel_config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,22 @@ processors:
176176
send_batch_max_size: 10000
177177
send_batch_size: 10000
178178
timeout: 30s
179+
resource/opentelemetry:
180+
attributes:
181+
- action: upsert
182+
converted_type: ""
183+
from_attribute: ""
184+
from_context: ""
185+
key: deployment.environment
186+
pattern: ""
187+
value: prod
188+
- action: upsert
189+
converted_type: ""
190+
from_attribute: ""
191+
from_context: ""
192+
key: team
193+
pattern: ""
194+
value: cloudwatch
179195
resourcedetection/opentelemetry:
180196
aks:
181197
resource_attributes:
@@ -582,6 +598,7 @@ service:
582598
exporters:
583599
- otlphttp/logs
584600
processors:
601+
- resource/opentelemetry
585602
- resourcedetection/opentelemetry
586603
- transform/identity
587604
- transform/logs_routing
@@ -603,6 +620,7 @@ service:
603620
exporters:
604621
- otlphttp/metrics
605622
processors:
623+
- resource/opentelemetry
606624
- resourcedetection/opentelemetry
607625
- transform/identity
608626
- batch/opentelemetry_metrics
@@ -620,6 +638,7 @@ service:
620638
exporters:
621639
- otlphttp/traces
622640
processors:
641+
- resource/opentelemetry
623642
- resourcedetection/opentelemetry
624643
- transform/identity
625644
- batch/opentelemetry_traces

translator/translate/otel/common/common.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const (
3232
TracesCollectedKey = "traces_collected"
3333
OpenTelemetryKey = "opentelemetry"
3434
CollectKey = "collect"
35+
ResourceAttributesKey = "resource_attributes"
3536
HostMetricsKey = "host_metrics"
3637
OtelContainerInsightsKey = "container_insights"
3738
MetricsDestinationsKey = "metrics_destinations"
@@ -169,6 +170,8 @@ var (
169170
OtelSpanMetricsEnabledKey = ConfigKey(OpenTelemetryKey, CollectKey, OtlpKey, "span_metrics_enabled")
170171
WindowsEventsConfigKey = ConfigKey(OpenTelemetryKey, CollectKey, WindowsEventsKey)
171172
FilesConfigKey = ConfigKey(OpenTelemetryKey, CollectKey, FilesKey)
173+
// OtelResourceAttributesKey holds customer-supplied resource attributes added to every opentelemetry export pipeline.
174+
OtelResourceAttributesKey = ConfigKey(OpenTelemetryKey, ResourceAttributesKey)
172175
)
173176

174177
const (
@@ -375,6 +378,26 @@ func GetString(conf *confmap.Conf, key string) (string, bool) {
375378
return "", false
376379
}
377380

381+
// GetStringMap gets the key/value pairs for the key as a map[string]string,
382+
// coercing non-string values to their string form. Returns nil if the key is
383+
// missing or is not a map.
384+
func GetStringMap(conf *confmap.Conf, key string) map[string]string {
385+
value := conf.Get(key)
386+
raw, ok := value.(map[string]any)
387+
if !ok {
388+
return nil
389+
}
390+
result := make(map[string]string, len(raw))
391+
for k, v := range raw {
392+
if s, ok := v.(string); ok {
393+
result[k] = s
394+
} else {
395+
result[k] = fmt.Sprintf("%v", v)
396+
}
397+
}
398+
return result
399+
}
400+
378401
// GetArray gets the array value for the key. If the key is missing,
379402
// the return value will be nil
380403
func GetArray[C any](conf *confmap.Conf, key string) []C {

translator/translate/otel/common/common_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ func TestGetString(t *testing.T) {
4949
require.Equal(t, "", got)
5050
}
5151

52+
func TestGetStringMap(t *testing.T) {
53+
conf := confmap.NewFromStringMap(map[string]any{
54+
"attrs": map[string]any{"team": "cloudwatch", "count": 3},
55+
"notAMap": "value",
56+
})
57+
got := GetStringMap(conf, "attrs")
58+
require.Equal(t, map[string]string{"team": "cloudwatch", "count": "3"}, got)
59+
60+
require.Nil(t, GetStringMap(conf, "notAMap"))
61+
require.Nil(t, GetStringMap(conf, "missing"))
62+
}
63+
5264
func TestGetArray(t *testing.T) {
5365
conf := confmap.NewFromStringMap(map[string]any{
5466
"int": []any{5, 8, 10},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package opentelemetry
5+
6+
import (
7+
"go.opentelemetry.io/collector/confmap"
8+
9+
"github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common"
10+
"github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/processor/resourceprocessor"
11+
)
12+
13+
// reservedResourceAttributeKeys are attributes the agent manages internally for
14+
// log routing; customers must not override them via resource_attributes.
15+
var reservedResourceAttributeKeys = []string{
16+
"aws.log.group.name",
17+
"aws.log.stream.name",
18+
"aws.log.source",
19+
}
20+
21+
// resourceAttributesProcessor returns a resource processor that upserts the
22+
// customer-supplied opentelemetry.resource_attributes onto every record, or nil
23+
// if none are configured. Callers place it at the front of the processor list so
24+
// the attributes are present before any downstream processing.
25+
//
26+
// Note: it runs before resourcedetection (override: true), so for keys the agent
27+
// also auto-detects (e.g. cloud.region, host.id) the detected value wins. This is
28+
// intentional; the field is for adding attributes the agent does not detect.
29+
func resourceAttributesProcessor(conf *confmap.Conf) common.ComponentTranslator {
30+
if conf == nil {
31+
return nil
32+
}
33+
attrs := common.GetStringMap(conf, common.OtelResourceAttributesKey)
34+
if len(attrs) == 0 {
35+
return nil
36+
}
37+
return resourceprocessor.NewTranslator(
38+
common.WithName(common.OpenTelemetryKey),
39+
resourceprocessor.WithAttributes(attrs),
40+
resourceprocessor.WithReservedKeys(reservedResourceAttributeKeys...),
41+
)
42+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package opentelemetry
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.opentelemetry.io/collector/confmap"
12+
)
13+
14+
func TestResourceAttributesProcessor(t *testing.T) {
15+
// nil conf -> no processor
16+
assert.Nil(t, resourceAttributesProcessor(nil))
17+
18+
// no resource_attributes key -> no processor
19+
conf := confmap.NewFromStringMap(map[string]interface{}{
20+
"opentelemetry": map[string]interface{}{"collect": map[string]interface{}{"otlp": map[string]interface{}{}}},
21+
})
22+
assert.Nil(t, resourceAttributesProcessor(conf))
23+
24+
// empty map -> no processor
25+
conf = confmap.NewFromStringMap(map[string]interface{}{
26+
"opentelemetry": map[string]interface{}{"resource_attributes": map[string]interface{}{}},
27+
})
28+
assert.Nil(t, resourceAttributesProcessor(conf))
29+
30+
// populated map -> processor present, Translate succeeds
31+
conf = confmap.NewFromStringMap(map[string]interface{}{
32+
"opentelemetry": map[string]interface{}{"resource_attributes": map[string]interface{}{"team": "cloudwatch"}},
33+
})
34+
tr := resourceAttributesProcessor(conf)
35+
require.NotNil(t, tr)
36+
assert.Equal(t, "resource/opentelemetry", tr.ID().String())
37+
cfg, err := tr.Translate(conf)
38+
require.NoError(t, err)
39+
assert.NotNil(t, cfg)
40+
}
41+
42+
func TestResourceAttributesProcessor_ReservedKeyRejected(t *testing.T) {
43+
for _, key := range reservedResourceAttributeKeys {
44+
t.Run(key, func(t *testing.T) {
45+
conf := confmap.NewFromStringMap(map[string]interface{}{
46+
"opentelemetry": map[string]interface{}{
47+
"resource_attributes": map[string]interface{}{key: "/hijacked"},
48+
},
49+
})
50+
tr := resourceAttributesProcessor(conf)
51+
require.NotNil(t, tr)
52+
cfg, err := tr.Translate(conf)
53+
require.Error(t, err)
54+
assert.Nil(t, cfg)
55+
assert.Contains(t, err.Error(), "reserved")
56+
})
57+
}
58+
}
59+
60+
func TestResourceAttributesProcessor_EmptyKeyRejected(t *testing.T) {
61+
conf := confmap.NewFromStringMap(map[string]interface{}{
62+
"opentelemetry": map[string]interface{}{
63+
"resource_attributes": map[string]interface{}{"": "value"},
64+
},
65+
})
66+
tr := resourceAttributesProcessor(conf)
67+
require.NotNil(t, tr)
68+
cfg, err := tr.Translate(conf)
69+
require.Error(t, err)
70+
assert.Nil(t, cfg)
71+
assert.Contains(t, err.Error(), "must not be empty")
72+
}

0 commit comments

Comments
 (0)