1// Copyright 2015 The Prometheus Authors
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14// Package push provides functions to push metrics to a Pushgateway. It uses a
15// builder approach. Create a Pusher with New and then add the various options
16// by using its methods, finally calling Add or Push, like this:
17//
18//    // Easy case:
19//    push.New("http://example.org/metrics", "my_job").Gatherer(myRegistry).Push()
20//
21//    // Complex case:
22//    push.New("http://example.org/metrics", "my_job").
23//        Collector(myCollector1).
24//        Collector(myCollector2).
25//        Grouping("zone", "xy").
26//        Client(&myHTTPClient).
27//        BasicAuth("top", "secret").
28//        Add()
29//
30// See the examples section for more detailed examples.
31//
32// See the documentation of the Pushgateway to understand the meaning of
33// the grouping key and the differences between Push and Add:
34// https://github.com/prometheus/pushgateway
35package push
36
37import (
38	"bytes"
39	"encoding/base64"
40	"errors"
41	"fmt"
42	"io/ioutil"
43	"net/http"
44	"net/url"
45	"strings"
46
47	"github.com/prometheus/common/expfmt"
48	"github.com/prometheus/common/model"
49
50	"github.com/prometheus/client_golang/prometheus"
51)
52
53const (
54	contentTypeHeader = "Content-Type"
55	// base64Suffix is appended to a label name in the request URL path to
56	// mark the following label value as base64 encoded.
57	base64Suffix = "@base64"
58)
59
60var errJobEmpty = errors.New("job name is empty")
61
62// HTTPDoer is an interface for the one method of http.Client that is used by Pusher
63type HTTPDoer interface {
64	Do(*http.Request) (*http.Response, error)
65}
66
67// Pusher manages a push to the Pushgateway. Use New to create one, configure it
68// with its methods, and finally use the Add or Push method to push.
69type Pusher struct {
70	error error
71
72	url, job string
73	grouping map[string]string
74
75	gatherers  prometheus.Gatherers
76	registerer prometheus.Registerer
77
78	client             HTTPDoer
79	useBasicAuth       bool
80	username, password string
81
82	expfmt expfmt.Format
83}
84
85// New creates a new Pusher to push to the provided URL with the provided job
86// name (which must not be empty). You can use just host:port or ip:port as url,
87// in which case “http://” is added automatically. Alternatively, include the
88// schema in the URL. However, do not include the “/metrics/jobs/…” part.
89func New(url, job string) *Pusher {
90	var (
91		reg = prometheus.NewRegistry()
92		err error
93	)
94	if job == "" {
95		err = errJobEmpty
96	}
97	if !strings.Contains(url, "://") {
98		url = "http://" + url
99	}
100	if strings.HasSuffix(url, "/") {
101		url = url[:len(url)-1]
102	}
103
104	return &Pusher{
105		error:      err,
106		url:        url,
107		job:        job,
108		grouping:   map[string]string{},
109		gatherers:  prometheus.Gatherers{reg},
110		registerer: reg,
111		client:     &http.Client{},
112		expfmt:     expfmt.FmtProtoDelim,
113	}
114}
115
116// Push collects/gathers all metrics from all Collectors and Gatherers added to
117// this Pusher. Then, it pushes them to the Pushgateway configured while
118// creating this Pusher, using the configured job name and any added grouping
119// labels as grouping key. All previously pushed metrics with the same job and
120// other grouping labels will be replaced with the metrics pushed by this
121// call. (It uses HTTP method “PUT” to push to the Pushgateway.)
122//
123// Push returns the first error encountered by any method call (including this
124// one) in the lifetime of the Pusher.
125func (p *Pusher) Push() error {
126	return p.push(http.MethodPut)
127}
128
129// Add works like push, but only previously pushed metrics with the same name
130// (and the same job and other grouping labels) will be replaced. (It uses HTTP
131// method “POST” to push to the Pushgateway.)
132func (p *Pusher) Add() error {
133	return p.push(http.MethodPost)
134}
135
136// Gatherer adds a Gatherer to the Pusher, from which metrics will be gathered
137// to push them to the Pushgateway. The gathered metrics must not contain a job
138// label of their own.
139//
140// For convenience, this method returns a pointer to the Pusher itself.
141func (p *Pusher) Gatherer(g prometheus.Gatherer) *Pusher {
142	p.gatherers = append(p.gatherers, g)
143	return p
144}
145
146// Collector adds a Collector to the Pusher, from which metrics will be
147// collected to push them to the Pushgateway. The collected metrics must not
148// contain a job label of their own.
149//
150// For convenience, this method returns a pointer to the Pusher itself.
151func (p *Pusher) Collector(c prometheus.Collector) *Pusher {
152	if p.error == nil {
153		p.error = p.registerer.Register(c)
154	}
155	return p
156}
157
158// Grouping adds a label pair to the grouping key of the Pusher, replacing any
159// previously added label pair with the same label name. Note that setting any
160// labels in the grouping key that are already contained in the metrics to push
161// will lead to an error.
162//
163// For convenience, this method returns a pointer to the Pusher itself.
164func (p *Pusher) Grouping(name, value string) *Pusher {
165	if p.error == nil {
166		if !model.LabelName(name).IsValid() {
167			p.error = fmt.Errorf("grouping label has invalid name: %s", name)
168			return p
169		}
170		p.grouping[name] = value
171	}
172	return p
173}
174
175// Client sets a custom HTTP client for the Pusher. For convenience, this method
176// returns a pointer to the Pusher itself.
177// Pusher only needs one method of the custom HTTP client: Do(*http.Request).
178// Thus, rather than requiring a fully fledged http.Client,
179// the provided client only needs to implement the HTTPDoer interface.
180// Since *http.Client naturally implements that interface, it can still be used normally.
181func (p *Pusher) Client(c HTTPDoer) *Pusher {
182	p.client = c
183	return p
184}
185
186// BasicAuth configures the Pusher to use HTTP Basic Authentication with the
187// provided username and password. For convenience, this method returns a
188// pointer to the Pusher itself.
189func (p *Pusher) BasicAuth(username, password string) *Pusher {
190	p.useBasicAuth = true
191	p.username = username
192	p.password = password
193	return p
194}
195
196// Format configures the Pusher to use an encoding format given by the
197// provided expfmt.Format. The default format is expfmt.FmtProtoDelim and
198// should be used with the standard Prometheus Pushgateway. Custom
199// implementations may require different formats. For convenience, this
200// method returns a pointer to the Pusher itself.
201func (p *Pusher) Format(format expfmt.Format) *Pusher {
202	p.expfmt = format
203	return p
204}
205
206// Delete sends a “DELETE” request to the Pushgateway configured while creating
207// this Pusher, using the configured job name and any added grouping labels as
208// grouping key. Any added Gatherers and Collectors added to this Pusher are
209// ignored by this method.
210//
211// Delete returns the first error encountered by any method call (including this
212// one) in the lifetime of the Pusher.
213func (p *Pusher) Delete() error {
214	if p.error != nil {
215		return p.error
216	}
217	req, err := http.NewRequest(http.MethodDelete, p.fullURL(), nil)
218	if err != nil {
219		return err
220	}
221	if p.useBasicAuth {
222		req.SetBasicAuth(p.username, p.password)
223	}
224	resp, err := p.client.Do(req)
225	if err != nil {
226		return err
227	}
228	defer resp.Body.Close()
229	if resp.StatusCode != http.StatusAccepted {
230		body, _ := ioutil.ReadAll(resp.Body) // Ignore any further error as this is for an error message only.
231		return fmt.Errorf("unexpected status code %d while deleting %s: %s", resp.StatusCode, p.fullURL(), body)
232	}
233	return nil
234}
235
236func (p *Pusher) push(method string) error {
237	if p.error != nil {
238		return p.error
239	}
240	mfs, err := p.gatherers.Gather()
241	if err != nil {
242		return err
243	}
244	buf := &bytes.Buffer{}
245	enc := expfmt.NewEncoder(buf, p.expfmt)
246	// Check for pre-existing grouping labels:
247	for _, mf := range mfs {
248		for _, m := range mf.GetMetric() {
249			for _, l := range m.GetLabel() {
250				if l.GetName() == "job" {
251					return fmt.Errorf("pushed metric %s (%s) already contains a job label", mf.GetName(), m)
252				}
253				if _, ok := p.grouping[l.GetName()]; ok {
254					return fmt.Errorf(
255						"pushed metric %s (%s) already contains grouping label %s",
256						mf.GetName(), m, l.GetName(),
257					)
258				}
259			}
260		}
261		enc.Encode(mf)
262	}
263	req, err := http.NewRequest(method, p.fullURL(), buf)
264	if err != nil {
265		return err
266	}
267	if p.useBasicAuth {
268		req.SetBasicAuth(p.username, p.password)
269	}
270	req.Header.Set(contentTypeHeader, string(p.expfmt))
271	resp, err := p.client.Do(req)
272	if err != nil {
273		return err
274	}
275	defer resp.Body.Close()
276	// Depending on version and configuration of the PGW, StatusOK or StatusAccepted may be returned.
277	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
278		body, _ := ioutil.ReadAll(resp.Body) // Ignore any further error as this is for an error message only.
279		return fmt.Errorf("unexpected status code %d while pushing to %s: %s", resp.StatusCode, p.fullURL(), body)
280	}
281	return nil
282}
283
284// fullURL assembles the URL used to push/delete metrics and returns it as a
285// string. The job name and any grouping label values containing a '/' will
286// trigger a base64 encoding of the affected component and proper suffixing of
287// the preceding component. Similarly, an empty grouping label value will be
288// encoded as base64 just with a single `=` padding character (to avoid an empty
289// path component). If the component does not contain a '/' but other special
290// characters, the usual url.QueryEscape is used for compatibility with older
291// versions of the Pushgateway and for better readability.
292func (p *Pusher) fullURL() string {
293	urlComponents := []string{}
294	if encodedJob, base64 := encodeComponent(p.job); base64 {
295		urlComponents = append(urlComponents, "job"+base64Suffix, encodedJob)
296	} else {
297		urlComponents = append(urlComponents, "job", encodedJob)
298	}
299	for ln, lv := range p.grouping {
300		if encodedLV, base64 := encodeComponent(lv); base64 {
301			urlComponents = append(urlComponents, ln+base64Suffix, encodedLV)
302		} else {
303			urlComponents = append(urlComponents, ln, encodedLV)
304		}
305	}
306	return fmt.Sprintf("%s/metrics/%s", p.url, strings.Join(urlComponents, "/"))
307}
308
309// encodeComponent encodes the provided string with base64.RawURLEncoding in
310// case it contains '/' and as "=" in case it is empty. If neither is the case,
311// it uses url.QueryEscape instead. It returns true in the former two cases.
312func encodeComponent(s string) (string, bool) {
313	if s == "" {
314		return "=", true
315	}
316	if strings.Contains(s, "/") {
317		return base64.RawURLEncoding.EncodeToString([]byte(s)), true
318	}
319	return url.QueryEscape(s), false
320}
321