5
5
"context"
6
6
"fmt"
7
7
"io"
8
+ "math"
9
+ "mime"
8
10
"net"
9
11
"net/http"
10
12
"net/url"
28
30
var (
29
31
prometheusRequestHeaders = []string {"accept" , "accept-encoding" , "user-agent" , "x-prometheus-scrape-timeout-seconds" }
30
32
logger = core .Log .WithName ("metrics-hijacker" )
33
+
34
+ // holds prometheus content types in order of priority.
35
+ prometheusPriorityContentType = []expfmt.Format {
36
+ expfmt .FmtOpenMetrics_1_0_0 ,
37
+ expfmt .FmtOpenMetrics_0_0_1 ,
38
+ expfmt .FmtText ,
39
+ expfmt .FmtUnknown ,
40
+ }
41
+
42
+ // Reverse mapping of prometheusPriorityContentType for faster lookup.
43
+ prometheusPriorityContentTypeLookup = func (expformats []expfmt.Format ) map [expfmt.Format ]int32 {
44
+ reverseMapping := map [expfmt.Format ]int32 {}
45
+ for priority , format := range expformats {
46
+ reverseMapping [format ] = int32 (priority )
47
+ }
48
+ return reverseMapping
49
+ }(prometheusPriorityContentType )
31
50
)
32
51
33
52
var _ component.Component = & Hijacker {}
@@ -151,44 +170,135 @@ func rewriteMetricsURL(address string, port uint32, path string, queryModifier Q
151
170
func (s * Hijacker ) ServeHTTP (writer http.ResponseWriter , req * http.Request ) {
152
171
ctx := req .Context ()
153
172
out := make (chan []byte , len (s .applicationsToScrape ))
173
+ contentTypes := make (chan expfmt.Format , len (s .applicationsToScrape ))
154
174
var wg sync.WaitGroup
155
175
done := make (chan []byte )
156
176
wg .Add (len (s .applicationsToScrape ))
157
177
go func () {
158
178
wg .Wait ()
159
- close (done )
160
179
close (out )
180
+ close (contentTypes )
181
+ close (done )
161
182
}()
162
183
163
184
for _ , app := range s .applicationsToScrape {
164
185
go func (app ApplicationToScrape ) {
165
186
defer wg .Done ()
166
- out <- s .getStats (ctx , req , app )
187
+ content , contentType := s .getStats (ctx , req , app )
188
+ out <- content
189
+
190
+ // It's possible to track the highest priority content type seen,
191
+ // but that would require mutex.
192
+ // I would prefer to calculate it later at one go
193
+ contentTypes <- contentType
167
194
}(app )
168
195
}
169
196
170
197
select {
171
198
case <- ctx .Done ():
172
199
return
173
200
case <- done :
174
- // default format returned by prometheus
175
- writer .Header ().Set ("content-type" , string (expfmt .FmtText ))
176
- for resp := range out {
177
- if _ , err := writer .Write (resp ); err != nil {
178
- logger .Error (err , "error while writing the response" )
179
- }
180
- if _ , err := writer .Write ([]byte ("\n " )); err != nil {
181
- logger .Error (err , "error while writing the response" )
182
- }
201
+ selectedCt := selectContentType (contentTypes , req .Header )
202
+ writer .Header ().Set (hdrContentType , string (selectedCt ))
203
+
204
+ // aggregate metrics of target applications and attempt to make them
205
+ // compatible with FmtOpenMetrics if it is the selected content type.
206
+ metrics := processMetrics (out , selectedCt )
207
+ if _ , err := writer .Write (metrics ); err != nil {
208
+ logger .Error (err , "error while writing the response" )
183
209
}
184
210
}
185
211
}
186
212
187
- func (s * Hijacker ) getStats (ctx context.Context , initReq * http.Request , app ApplicationToScrape ) []byte {
213
+ func processMetrics (contents <- chan []byte , contentType expfmt.Format ) []byte {
214
+ buf := new (bytes.Buffer )
215
+
216
+ for metrics := range contents {
217
+ // remove the EOF marker from the metrics, because we are
218
+ // merging multiple metrics into one response.
219
+ metrics = bytes .ReplaceAll (metrics , []byte ("# EOF" ), []byte ("" ))
220
+
221
+ buf .Write (metrics )
222
+ buf .Write ([]byte ("\n " ))
223
+ }
224
+
225
+ processedMetrics := append (processNewlineChars (buf .Bytes ()), '\n' )
226
+ buf .Reset ()
227
+ buf .Write (processedMetrics )
228
+
229
+ if contentType == expfmt .FmtOpenMetrics_1_0_0 || contentType == expfmt .FmtOpenMetrics_0_0_1 {
230
+ // make metrics OpenMetrics compliant
231
+ buf .Write ([]byte ("# EOF\n " ))
232
+ }
233
+
234
+ return buf .Bytes ()
235
+ }
236
+
237
+ // processNewlineChars takes byte data and returns a new byte slice
238
+ // after trimming and deduplicating the newline characters.
239
+ func processNewlineChars (input []byte ) []byte {
240
+ var deduped []byte
241
+
242
+ if len (input ) == 0 {
243
+ return nil
244
+ }
245
+ last := input [0 ]
246
+
247
+ for i := 1 ; i < len (input ); i ++ {
248
+ if last == '\n' && input [i ] == last {
249
+ continue
250
+ }
251
+ deduped = append (deduped , last )
252
+ last = input [i ]
253
+ }
254
+ deduped = append (deduped , last )
255
+
256
+ return bytes .TrimSpace (deduped )
257
+ }
258
+
259
+ // selectContentType selects the highest priority content type supported by the applications.
260
+ // If no valid content type is returned by the applications, it negotiates content type based
261
+ // on Accept header of the scraper.
262
+ func selectContentType (contentTypes <- chan expfmt.Format , reqHeader http.Header ) expfmt.Format {
263
+ // Tracks highest negotiated content type priority.
264
+ // Lower number means higher priority
265
+ //
266
+ // We can not simply use the highest priority content type i.e. `application/openmetrics-text`
267
+ // and try to mutate the metrics to make it compatible with this type,
268
+ // because:
269
+ // - if the application is not supporting this type,
270
+ // custom metrics might not be compatible (more prone to failure).
271
+ // - the user might be using older prom scraper.
272
+ //
273
+ // So it's better to choose the highest negotiated content type between the
274
+ // target apps and the scraper.
275
+ var ctPriority int32 = math .MaxInt32
276
+ ct := expfmt .FmtUnknown
277
+ for contentType := range contentTypes {
278
+ priority , valid := prometheusPriorityContentTypeLookup [contentType ]
279
+ if ! valid {
280
+ continue
281
+ }
282
+ if priority < ctPriority {
283
+ ctPriority = priority
284
+ ct = contentType
285
+ }
286
+ }
287
+
288
+ // If no valid content type is returned by the target applications,
289
+ // negotitate content type based on Accept header of the scraper.
290
+ if ct == expfmt .FmtUnknown {
291
+ ct = expfmt .Negotiate (reqHeader )
292
+ }
293
+
294
+ return ct
295
+ }
296
+
297
+ func (s * Hijacker ) getStats (ctx context.Context , initReq * http.Request , app ApplicationToScrape ) ([]byte , expfmt.Format ) {
188
298
req , err := http .NewRequest ("GET" , rewriteMetricsURL (app .Address , app .Port , app .Path , app .QueryModifier , initReq .URL ), nil )
189
299
if err != nil {
190
300
logger .Error (err , "failed to create request" )
191
- return nil
301
+ return nil , ""
192
302
}
193
303
s .passRequestHeaders (req .Header , initReq .Header )
194
304
req = req .WithContext (ctx )
@@ -206,25 +316,27 @@ func (s *Hijacker) getStats(ctx context.Context, initReq *http.Request, app Appl
206
316
}
207
317
if err != nil {
208
318
logger .Error (err , "failed call" , "name" , app .Name , "path" , app .Path , "port" , app .Port )
209
- return nil
319
+ return nil , ""
210
320
}
211
321
322
+ respContentType := responseFormat (resp .Header )
323
+
212
324
var bodyBytes []byte
213
325
if app .Mutator != nil {
214
326
buf := new (bytes.Buffer )
215
327
if err := app .Mutator (resp .Body , buf ); err != nil {
216
328
logger .Error (err , "failed while mutating data" , "name" , app .Name , "path" , app .Path , "port" , app .Port )
217
- return nil
329
+ return nil , ""
218
330
}
219
331
bodyBytes = buf .Bytes ()
220
332
} else {
221
333
bodyBytes , err = io .ReadAll (resp .Body )
222
334
if err != nil {
223
335
logger .Error (err , "failed while writing" , "name" , app .Name , "path" , app .Path , "port" , app .Port )
224
- return nil
336
+ return nil , ""
225
337
}
226
338
}
227
- return bodyBytes
339
+ return bodyBytes , respContentType
228
340
}
229
341
230
342
func (s * Hijacker ) passRequestHeaders (into http.Header , from http.Header ) {
@@ -241,3 +353,49 @@ func (s *Hijacker) passRequestHeaders(into http.Header, from http.Header) {
241
353
func (s * Hijacker ) NeedLeaderElection () bool {
242
354
return false
243
355
}
356
+
357
+ const (
358
+ hdrContentType = "Content-Type"
359
+ textType = "text/plain"
360
+ )
361
+
362
+ // responseFormat extracts the correct format from a HTTP response header.
363
+ // If no matching format can be found FormatUnknown is returned.
364
+ func responseFormat (h http.Header ) expfmt.Format {
365
+ ct := h .Get (hdrContentType )
366
+
367
+ mediatype , params , err := mime .ParseMediaType (ct )
368
+ if err != nil {
369
+ return expfmt .FmtUnknown
370
+ }
371
+
372
+ version := params ["version" ]
373
+
374
+ switch mediatype {
375
+ case expfmt .ProtoType :
376
+ p := params ["proto" ]
377
+ e := params ["encoding" ]
378
+ // only delimited encoding is supported by prometheus scraper
379
+ if p == expfmt .ProtoProtocol && e == "delimited" {
380
+ return expfmt .FmtProtoDelim
381
+ }
382
+
383
+ // if mediatype is `text/plain`, return Prometheus text format
384
+ // without checking the version, as there are few exporters
385
+ // which don't set the version param in the content-type header. ex: Envoy
386
+ case textType :
387
+ return expfmt .FmtText
388
+
389
+ // if mediatype is OpenMetricsType, return FmtUnknown for any version
390
+ // other than "0.0.1", "1.0.0" and "".
391
+ case expfmt .OpenMetricsType :
392
+ if version == expfmt .OpenMetricsVersion_0_0_1 || version == "" {
393
+ return expfmt .FmtOpenMetrics_0_0_1
394
+ }
395
+ if version == expfmt .OpenMetricsVersion_1_0_0 {
396
+ return expfmt .FmtOpenMetrics_1_0_0
397
+ }
398
+ }
399
+
400
+ return expfmt .FmtUnknown
401
+ }
0 commit comments