Skip to content

Commit 4cf61e3

Browse files
gizasdashpole
andauthored
[PrometheusRemoteWriter] Add support of MetadataKeys in remote write outbound requests (#48767)
<!--Ex. Fixing a bug - Describe the bug and how this fixes the issue. Ex. Adding a feature - Explain what this achieves.--> #### Description Adds the support of IncludeMetadataKeys in the prometehus remote write queue. When set, the listed client metadata keys are read from the request context and forwarded as HTTP headers Related to #33137 (comment) <!-- Issue number (e.g. #1234) or full URL to issue, if applicable. --> #### Link to tracking issue With this feature we preserver the metadata from the prometheus exporter to outer http requests. The metadata can be useful information to be used to handle this egress traffic. <!--Describe what testing was performed and which tests were added.--> #### Testing Added TestIncludeMetadataKeys and TestIncludeMetadataKeysAbsentWhenNotConfigured in opentelemetry-collector-contrib/exporter/prometheusremotewriteexporter/exporter_test.go <!--Describe the documentation added.--> #### Documentation Updated README.md file --------- Signed-off-by: Andreas Gkizas <andreas.gkizas@elastic.co> Co-authored-by: David Ashpole <dashpole@google.com>
1 parent 7592162 commit 4cf61e3

9 files changed

Lines changed: 235 additions & 2 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
7+
component: exporter/prometheus_remote_write
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Add `include_metadata_keys` to `remote_write_queue` configuration to forward client metadata as HTTP headers.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [47317]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]
28+

exporter/prometheusremotewriteexporter/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ The following settings can be optionally configured:
5252
- `add_metric_suffixes`: If set to false, type and unit suffixes will not be added to metrics. Default: true. **Deprecated**: Use `translation_strategy` instead.
5353
- `translation_strategy`: Controls how OTLP metric and attribute names are translated into Prometheus metric and label names. Options are: `UnderscoreEscapingWithSuffixes` (default), `UnderscoreEscapingWithoutSuffixes`, `NoUTF8EscapingWithSuffixes`, and `NoTranslation`. When set, this takes precedence over `add_metric_suffixes`.
5454
- `send_metadata`: If set to true, prometheus metadata will be generated and sent. Default: false. This option is ignored when using PRW 2.0, which always includes metadata.
55+
- `include_metadata_keys`: list of client metadata keys whose values are forwarded as HTTP headers on every outbound remote write request.
56+
- **Note**: Keys that collide with headers required by the remote write protocol (`Content-Encoding`, `Content-Type`, `User-Agent`, `X-Prometheus-Remote-Write-Version`) are rejected: configuration validation fails (case-insensitive) if any such key is listed.
57+
- **Note**: When WAL is enabled, then this setting has no effect. The WAL persists only the raw protobuf bytes of each write request; the originating `client.Info` context is not serialized to disk so initial metadata are lost.
5558
- `remote_write_queue`: fine tuning for queueing and sending of the outgoing remote writes.
5659
- `enabled`: enable the sending queue (default: `true`)
5760
- `queue_size`: number of OTLP metrics that can be queued. Ignored if `enabled` is `false` (default: `10000`)

exporter/prometheusremotewriteexporter/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package prometheusremotewriteexporter // import "github.com/open-telemetry/opent
66
import (
77
"errors"
88
"fmt"
9+
"net/textproto"
910

1011
remoteapi "github.com/prometheus/client_golang/exp/api/remote"
1112
"go.opentelemetry.io/collector/component"
@@ -72,6 +73,10 @@ type Config struct {
7273

7374
// RemoteWriteProtoMsg controls whether prometheus remote write v1 or v2 is sent.
7475
RemoteWriteProtoMsg remoteapi.WriteMessageType `mapstructure:"protobuf_message,omitempty"`
76+
77+
// IncludeMetadataKeys is a list of client metadata keys whose values are
78+
// forwarded as HTTP request headers on every remote write call.
79+
IncludeMetadataKeys []string `mapstructure:"include_metadata_keys"`
7580
}
7681

7782
type translationStrategy string
@@ -118,6 +123,16 @@ type RemoteWriteQueue struct {
118123

119124
// TODO(jbd): Add capacity, max_samples_per_send to QueueConfig.
120125

126+
// reservedRemoteWriteHeaders are managed by the remote write protocol itself and
127+
// must not be overwritten by headers forwarded from client metadata. Keys are
128+
// stored in canonical MIME form for case-insensitive matching.
129+
var reservedRemoteWriteHeaders = map[string]struct{}{
130+
textproto.CanonicalMIMEHeaderKey("Content-Encoding"): {},
131+
textproto.CanonicalMIMEHeaderKey("Content-Type"): {},
132+
textproto.CanonicalMIMEHeaderKey("User-Agent"): {},
133+
textproto.CanonicalMIMEHeaderKey("X-Prometheus-Remote-Write-Version"): {},
134+
}
135+
121136
var _ component.Config = (*Config)(nil)
122137

123138
// Validate checks if the exporter configuration is valid
@@ -172,5 +187,11 @@ func (cfg *Config) Validate() error {
172187
}
173188
}
174189

190+
for _, key := range cfg.IncludeMetadataKeys {
191+
if _, reserved := reservedRemoteWriteHeaders[textproto.CanonicalMIMEHeaderKey(key)]; reserved {
192+
return fmt.Errorf("include_metadata_keys entry %q collides with a reserved remote write header", key)
193+
}
194+
}
195+
175196
return nil
176197
}

exporter/prometheusremotewriteexporter/config.schema.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ properties:
4545
type: object
4646
additionalProperties:
4747
type: string
48+
include_metadata_keys:
49+
description: IncludeMetadataKeys is a list of client metadata keys whose values are forwarded as HTTP request headers on every remote write call.
50+
type: array
51+
items:
52+
type: string
4853
max_batch_request_parallelism:
4954
description: maximum amount of parallel requests to do when handling large batch request
5055
x-pointer: true

exporter/prometheusremotewriteexporter/config_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,49 @@ func TestLoadConfig(t *testing.T) {
158158
id: component.NewIDWithName(metadata.Type, "v1_no_translation"),
159159
errorMessage: "translation strategy NoTranslation requires Prometheus Remote Write 2.0",
160160
},
161+
{
162+
id: component.NewIDWithName(metadata.Type, "reserved_metadata_keys"),
163+
errorMessage: "include_metadata_keys entry \"content-type\" collides with a reserved remote write header",
164+
},
161165
{
162166
id: component.NewIDWithName(metadata.Type, "negative_max_batch_size_bytes"),
163167
errorMessage: "max_batch_size_bytes must be greater than 0",
164168
},
169+
{
170+
id: component.NewIDWithName(metadata.Type, "include_metadata_keys"),
171+
expected: &Config{
172+
MaxBatchSizeBytes: 3000000,
173+
MaxBatchRequestParallelism: nil,
174+
TimeoutSettings: exporterhelper.NewDefaultTimeoutConfig(),
175+
BackOffConfig: configretry.BackOffConfig{
176+
Enabled: true,
177+
InitialInterval: 50 * time.Millisecond,
178+
RandomizationFactor: 0.5,
179+
Multiplier: 1.5,
180+
MaxInterval: 30 * time.Second,
181+
MaxElapsedTime: 5 * time.Minute,
182+
},
183+
RemoteWriteQueue: RemoteWriteQueue{
184+
Enabled: true,
185+
QueueSize: 1000,
186+
NumConsumers: 5,
187+
},
188+
IncludeMetadataKeys: []string{"target-id", "x-org-id"},
189+
ExternalLabels: map[string]string{},
190+
AddMetricSuffixes: true,
191+
ClientConfig: func() confighttp.ClientConfig {
192+
cc := confighttp.NewDefaultClientConfig()
193+
cc.Endpoint = "localhost:8888"
194+
cc.WriteBufferSize = 512 * 1024
195+
cc.Timeout = 5 * time.Second
196+
return cc
197+
}(),
198+
RemoteWriteProtoMsg: remoteapi.WriteV1MessageType,
199+
TargetInfo: TargetInfo{
200+
Enabled: true,
201+
},
202+
},
203+
},
165204
}
166205

167206
for _, tt := range tests {

exporter/prometheusremotewriteexporter/exporter.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
remoteapi "github.com/prometheus/client_golang/exp/api/remote"
2222
"github.com/prometheus/otlptranslator"
2323
"github.com/prometheus/prometheus/prompb"
24+
"go.opentelemetry.io/collector/client"
2425
"go.opentelemetry.io/collector/component"
2526
"go.opentelemetry.io/collector/config/confighttp"
2627
"go.opentelemetry.io/collector/config/configretry"
@@ -139,6 +140,9 @@ type prwExporter struct {
139140
exporterSettings prometheusremotewrite.Settings
140141
telemetry prwTelemetry
141142
RemoteWriteProtoMsg remoteapi.WriteMessageType
143+
// includeMetadataKeys lists client metadata keys that are forwarded as
144+
// HTTP headers on every outbound remote write request.
145+
includeMetadataKeys []string
142146

143147
// When concurrency is enabled, concurrent goroutines would potentially
144148
// fight over the same batchState object. To avoid this, we use a pool
@@ -211,6 +215,7 @@ func newPRWExporter(cfg *Config, set exporter.Settings) (*prwExporter, error) {
211215
retrySettings: cfg.BackOffConfig,
212216
retryOnHTTP429: metadata.ExporterPrometheusremotewritexporterRetryOn429FeatureGate.IsEnabled(),
213217
RemoteWriteProtoMsg: cfg.RemoteWriteProtoMsg,
218+
includeMetadataKeys: cfg.IncludeMetadataKeys,
214219
exporterSettings: prometheusremotewrite.Settings{
215220
Namespace: cfg.Namespace,
216221
ExternalLabels: sanitizedLabels,
@@ -456,6 +461,16 @@ func (prwe *prwExporter) execute(ctx context.Context, buf []byte) error {
456461
return http.StatusBadRequest, fmt.Errorf("unsupported remote-write protobuf message: %v (should be validated earlier)", prwe.RemoteWriteProtoMsg)
457462
}
458463

464+
// Forward configured client metadata keys as HTTP headers
465+
if len(prwe.includeMetadataKeys) > 0 {
466+
md := client.FromContext(ctx).Metadata
467+
for _, key := range prwe.includeMetadataKeys {
468+
for _, val := range md.Get(key) {
469+
req.Header.Add(key, val)
470+
}
471+
}
472+
}
473+
459474
resp, err := prwe.client.Do(req)
460475
prwe.telemetry.recordRemoteWriteSentBatch(ctx)
461476
if err != nil {

exporter/prometheusremotewriteexporter/exporter_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
writev2 "github.com/prometheus/prometheus/prompb/io/prometheus/write/v2"
2626
"github.com/stretchr/testify/assert"
2727
"github.com/stretchr/testify/require"
28+
"go.opentelemetry.io/collector/client"
2829
"go.opentelemetry.io/collector/component"
2930
"go.opentelemetry.io/collector/component/componenttest"
3031
"go.opentelemetry.io/collector/config/confighttp"
@@ -1371,6 +1372,114 @@ func BenchmarkPushMetrics(b *testing.B) {
13711372
}
13721373
}
13731374

1375+
// TestIncludeMetadataKeys verifies that keys listed in
1376+
// include_metadata_keys are read from the client metadata
1377+
// context and forwarded as HTTP headers on every outbound remote write request.
1378+
func TestIncludeMetadataKeys(t *testing.T) {
1379+
// Capture headers received by the mock remote write server.
1380+
var (
1381+
mu sync.Mutex
1382+
receivedHeaders http.Header
1383+
)
1384+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1385+
mu.Lock()
1386+
receivedHeaders = r.Header.Clone()
1387+
mu.Unlock()
1388+
w.WriteHeader(http.StatusNoContent)
1389+
}))
1390+
defer server.Close()
1391+
1392+
clientConfig := confighttp.NewDefaultClientConfig()
1393+
clientConfig.Endpoint = server.URL
1394+
cfg := &Config{
1395+
ClientConfig: clientConfig,
1396+
RemoteWriteQueue: RemoteWriteQueue{NumConsumers: 1},
1397+
IncludeMetadataKeys: []string{
1398+
"target-id",
1399+
"target-type",
1400+
},
1401+
TargetInfo: TargetInfo{Enabled: true},
1402+
BackOffConfig: configretry.NewDefaultBackOffConfig(),
1403+
RemoteWriteProtoMsg: remoteapi.WriteV1MessageType,
1404+
}
1405+
1406+
set := exportertest.NewNopSettings(metadata.Type)
1407+
prwe, err := newPRWExporter(cfg, set)
1408+
require.NoError(t, err)
1409+
require.NoError(t, prwe.Start(t.Context(), componenttest.NewNopHost()))
1410+
t.Cleanup(func() { require.NoError(t, prwe.Shutdown(context.Background())) }) //nolint:usetesting
1411+
1412+
// Build a context carrying the metadata keys that should be forwarded.
1413+
ctx := client.NewContext(t.Context(), client.Info{
1414+
Metadata: client.NewMetadata(map[string][]string{
1415+
"target-id": {"my-project-123"},
1416+
"target-type": {"server-type"},
1417+
}),
1418+
})
1419+
1420+
// execute() requires a snappy-encoded protobuf body. An empty WriteRequest
1421+
// is valid enough for the mock server to accept.
1422+
emptyBuf, err := (&buffer{}).MarshalAndEncode(&prompb.WriteRequest{})
1423+
require.NoError(t, err)
1424+
1425+
require.NoError(t, prwe.execute(ctx, emptyBuf))
1426+
1427+
mu.Lock()
1428+
defer mu.Unlock()
1429+
assert.Equal(t, "my-project-123", receivedHeaders.Get("target-id"),
1430+
"target-id header should be forwarded from client metadata")
1431+
assert.Equal(t, "server-type", receivedHeaders.Get("target-type"),
1432+
"target-type header should be forwarded from client metadata")
1433+
}
1434+
1435+
// TestIncludeMetadataKeysAbsentWhenNotConfigured verifies that no extra headers
1436+
// are added when include_metadata_keys is empty (the default).
1437+
func TestIncludeMetadataKeysAbsentWhenNotConfigured(t *testing.T) {
1438+
var (
1439+
mu sync.Mutex
1440+
receivedHeaders http.Header
1441+
)
1442+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1443+
mu.Lock()
1444+
receivedHeaders = r.Header.Clone()
1445+
mu.Unlock()
1446+
w.WriteHeader(http.StatusNoContent)
1447+
}))
1448+
defer server.Close()
1449+
1450+
clientConfig := confighttp.NewDefaultClientConfig()
1451+
clientConfig.Endpoint = server.URL
1452+
cfg := &Config{
1453+
ClientConfig: clientConfig,
1454+
RemoteWriteQueue: RemoteWriteQueue{NumConsumers: 1},
1455+
TargetInfo: TargetInfo{Enabled: true},
1456+
BackOffConfig: configretry.NewDefaultBackOffConfig(),
1457+
RemoteWriteProtoMsg: remoteapi.WriteV1MessageType,
1458+
}
1459+
1460+
set := exportertest.NewNopSettings(metadata.Type)
1461+
prwe, err := newPRWExporter(cfg, set)
1462+
require.NoError(t, err)
1463+
require.NoError(t, prwe.Start(t.Context(), componenttest.NewNopHost()))
1464+
t.Cleanup(func() { require.NoError(t, prwe.Shutdown(context.Background())) }) //nolint:usetesting
1465+
1466+
ctx := client.NewContext(t.Context(), client.Info{
1467+
Metadata: client.NewMetadata(map[string][]string{
1468+
"target-id": {"my-project-123"},
1469+
}),
1470+
})
1471+
1472+
emptyBuf, err := (&buffer{}).MarshalAndEncode(&prompb.WriteRequest{})
1473+
require.NoError(t, err)
1474+
1475+
require.NoError(t, prwe.execute(ctx, emptyBuf))
1476+
1477+
mu.Lock()
1478+
defer mu.Unlock()
1479+
assert.Empty(t, receivedHeaders.Get("target-id"),
1480+
"target-id should not be forwarded when not listed in include_metadata_keys")
1481+
}
1482+
13741483
func BenchmarkPushMetricsVaryingMetrics(b *testing.B) {
13751484
benchmarkPushMetrics(b, -1, 1)
13761485
}

exporter/prometheusremotewriteexporter/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/prometheus/prometheus v0.313.1
1717
github.com/stretchr/testify v1.11.1
1818
github.com/tidwall/wal v1.2.1
19+
go.opentelemetry.io/collector/client v1.63.1-0.20260723141305-52e6bf4aaaba
1920
go.opentelemetry.io/collector/component v1.63.1-0.20260723141305-52e6bf4aaaba
2021
go.opentelemetry.io/collector/component/componenttest v0.157.1-0.20260723141305-52e6bf4aaaba
2122
go.opentelemetry.io/collector/config/confighttp v0.157.1-0.20260723141305-52e6bf4aaaba
@@ -115,7 +116,6 @@ require (
115116
github.com/tidwall/pretty v1.2.0 // indirect
116117
github.com/tidwall/tinylru v1.1.0 // indirect
117118
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
118-
go.opentelemetry.io/collector/client v1.63.1-0.20260723141305-52e6bf4aaaba // indirect
119119
go.opentelemetry.io/collector/config/configauth v1.63.1-0.20260723141305-52e6bf4aaaba // indirect
120120
go.opentelemetry.io/collector/config/configcompression v1.63.1-0.20260723141305-52e6bf4aaaba // indirect
121121
go.opentelemetry.io/collector/config/configmiddleware v1.63.1-0.20260723141305-52e6bf4aaaba // indirect

exporter/prometheusremotewriteexporter/testdata/config.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ prometheus_remote_write/v1_no_translation:
7878
protobuf_message: "prometheus.WriteRequest"
7979
translation_strategy: "NoTranslation"
8080

81+
prometheus_remote_write/include_metadata_keys:
82+
endpoint: "localhost:8888"
83+
include_metadata_keys:
84+
- "target-id"
85+
- "x-org-id"
86+
remote_write_queue:
87+
queue_size: 1000
88+
num_consumers: 5
89+
90+
prometheus_remote_write/reserved_metadata_keys:
91+
endpoint: "localhost:8888"
92+
include_metadata_keys:
93+
- "content-type"
8194
prometheus_remote_write/negative_max_batch_size_bytes:
8295
endpoint: "localhost:8888"
83-
max_batch_size_bytes: -1
96+
max_batch_size_bytes: -1

0 commit comments

Comments
 (0)