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	"fmt"
41	"io/ioutil"
42	"net/http"
43	"net/url"
44	"strings"
45
46	"github.com/prometheus/common/expfmt"
47	"github.com/prometheus/common/model"
48
49	"github.com/prometheus/client_golang/prometheus"
50)
51
52const (
53	contentTypeHeader = "Content-Type"
54	// base64Suffix is appended to a label name in the request URL path to
55	// mark the following label value as base64 encoded.
56	base64Suffix = "@base64"
57)
58
59// HTTPDoer is an interface for the one method of http.Client that is used by Pusher
60type HTTPDoer interface {
61	Do(*http.Request) (*http.Response, error)
62}
63
64// Pusher manages a push to the Pushgateway. Use New to create one, configure it
65// with its methods, and finally use the Add or Push method to push.
66type Pusher struct {
67	error error
68
69	url, job string
70	grouping map[string]string
71
72	gatherers  prometheus.Gatherers
73	registerer prometheus.Registerer
74
75	client             HTTPDoer
76	useBasicAuth       bool
77	username, password string
78
79	expfmt expfmt.Format
80}
81
82// New creates a new Pusher to push to the provided URL with the provided job
83// name. You can use just host:port or ip:port as url, in which case “http://
84// is added automatically. Alternatively, include the schema in the
85// URL. However, do not include the “/metrics/jobs/…” part.
86func New(url, job string) *Pusher {
87	var (
88		reg = prometheus.NewRegistry()
89		err error
90	)
91	if !strings.Contains(url, "://") {
92		url = "http://" + url
93	}
94	if strings.HasSuffix(url, "/") {
95		url = url[:len(url)-1]
96	}
97
98	return &Pusher{
99		error:      err,
100		url:        url,
101		job:        job,
102		grouping:   map[string]string{},
103		gatherers:  prometheus.Gatherers{reg},
104		registerer: reg,
105		client:     &http.Client{},
106		expfmt:     expfmt.FmtProtoDelim,
107	}
108}
109
110// Push collects/gathers all metrics from all Collectors and Gatherers added to
111// this Pusher. Then, it pushes them to the Pushgateway configured while
112// creating this Pusher, using the configured job name and any added grouping
113// labels as grouping key. All previously pushed metrics with the same job and
114// other grouping labels will be replaced with the metrics pushed by this
115// call. (It uses HTTP method “PUT” to push to the Pushgateway.)
116//
117// Push returns the first error encountered by any method call (including this
118// one) in the lifetime of the Pusher.
119func (p *Pusher) Push() error {
120	return p.push(http.MethodPut)
121}
122
123// Add works like push, but only previously pushed metrics with the same name
124// (and the same job and other grouping labels) will be replaced. (It uses HTTP
125// method “POST” to push to the Pushgateway.)
126func (p *Pusher) Add() error {
127	return p.push(http.MethodPost)
128}
129
130// Gatherer adds a Gatherer to the Pusher, from which metrics will be gathered
131// to push them to the Pushgateway. The gathered metrics must not contain a job
132// label of their own.
133//
134// For convenience, this method returns a pointer to the Pusher itself.
135func (p *Pusher) Gatherer(g prometheus.Gatherer) *Pusher {
136	p.gatherers = append(p.gatherers, g)
137	return p
138}
139
140// Collector adds a Collector to the Pusher, from which metrics will be
141// collected to push them to the Pushgateway. The collected metrics must not
142// contain a job label of their own.
143//
144// For convenience, this method returns a pointer to the Pusher itself.
145func (p *Pusher) Collector(c prometheus.Collector) *Pusher {
146	if p.error == nil {
147		p.error = p.registerer.Register(c)
148	}
149	return p
150}
151
152// Grouping adds a label pair to the grouping key of the Pusher, replacing any
153// previously added label pair with the same label name. Note that setting any
154// labels in the grouping key that are already contained in the metrics to push
155// will lead to an error.
156//
157// For convenience, this method returns a pointer to the Pusher itself.
158func (p *Pusher) Grouping(name, value string) *Pusher {
159	if p.error == nil {
160		if !model.LabelName(name).IsValid() {
161			p.error = fmt.Errorf("grouping label has invalid name: %s", name)
162			return p
163		}
164		p.grouping[name] = value
165	}
166	return p
167}
168
169// Client sets a custom HTTP client for the Pusher. For convenience, this method
170// returns a pointer to the Pusher itself.
171// Pusher only needs one method of the custom HTTP client: Do(*http.Request).
172// Thus, rather than requiring a fully fledged http.Client,
173// the provided client only needs to implement the HTTPDoer interface.
174// Since *http.Client naturally implements that interface, it can still be used normally.
175func (p *Pusher) Client(c HTTPDoer) *Pusher {
176	p.client = c
177	return p
178}
179
180// BasicAuth configures the Pusher to use HTTP Basic Authentication with the
181// provided username and password. For convenience, this method returns a
182// pointer to the Pusher itself.
183func (p *Pusher) BasicAuth(username, password string) *Pusher {
184	p.useBasicAuth = true
185	p.username = username
186	p.password = password
187	return p
188}
189
190// Format configures the Pusher to use an encoding format given by the
191// provided expfmt.Format. The default format is expfmt.FmtProtoDelim and
192// should be used with the standard Prometheus Pushgateway. Custom
193// implementations may require different formats. For convenience, this
194// method returns a pointer to the Pusher itself.
195func (p *Pusher) Format(format expfmt.Format) *Pusher {
196	p.expfmt = format
197	return p
198}
199
200// Delete sends a “DELETE” request to the Pushgateway configured while creating
201// this Pusher, using the configured job name and any added grouping labels as
202// grouping key. Any added Gatherers and Collectors added to this Pusher are
203// ignored by this method.
204//
205// Delete returns the first error encountered by any method call (including this
206// one) in the lifetime of the Pusher.
207func (p *Pusher) Delete() error {
208	if p.error != nil {
209		return p.error
210	}
211	req, err := http.NewRequest(http.MethodDelete, p.fullURL(), nil)
212	if err != nil {
213		return err
214	}
215	if p.useBasicAuth {
216		req.SetBasicAuth(p.username, p.password)
217	}
218	resp, err := p.client.Do(req)
219	if err != nil {
220		return err
221	}
222	defer resp.Body.Close()
223	if resp.StatusCode != http.StatusAccepted {
224		body, _ := ioutil.ReadAll(resp.Body) // Ignore any further error as this is for an error message only.
225		return fmt.Errorf("unexpected status code %d while deleting %s: %s", resp.StatusCode, p.fullURL(), body)
226	}
227	return nil
228}
229
230func (p *Pusher) push(method string) error {
231	if p.error != nil {
232		return p.error
233	}
234	mfs, err := p.gatherers.Gather()
235	if err != nil {
236		return err
237	}
238	buf := &bytes.Buffer{}
239	enc := expfmt.NewEncoder(buf, p.expfmt)
240	// Check for pre-existing grouping labels:
241	for _, mf := range mfs {
242		for _, m := range mf.GetMetric() {
243			for _, l := range m.GetLabel() {
244				if l.GetName() == "job" {
245					return fmt.Errorf("pushed metric %s (%s) already contains a job label", mf.GetName(), m)
246				}
247				if _, ok := p.grouping[l.GetName()]; ok {
248					return fmt.Errorf(
249						"pushed metric %s (%s) already contains grouping label %s",
250						mf.GetName(), m, l.GetName(),
251					)
252				}
253			}
254		}
255		enc.Encode(mf)
256	}
257	req, err := http.NewRequest(method, p.fullURL(), buf)
258	if err != nil {
259		return err
260	}
261	if p.useBasicAuth {
262		req.SetBasicAuth(p.username, p.password)
263	}
264	req.Header.Set(contentTypeHeader, string(p.expfmt))
265	resp, err := p.client.Do(req)
266	if err != nil {
267		return err
268	}
269	defer resp.Body.Close()
270	// Pushgateway 0.10+ responds with StatusOK, earlier versions with StatusAccepted.
271	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
272		body, _ := ioutil.ReadAll(resp.Body) // Ignore any further error as this is for an error message only.
273		return fmt.Errorf("unexpected status code %d while pushing to %s: %s", resp.StatusCode, p.fullURL(), body)
274	}
275	return nil
276}
277
278// fullURL assembles the URL used to push/delete metrics and returns it as a
279// string. The job name and any grouping label values containing a '/' will
280// trigger a base64 encoding of the affected component and proper suffixing of
281// the preceding component. If the component does not contain a '/' but other
282// special character, the usual url.QueryEscape is used for compatibility with
283// older versions of the Pushgateway and for better readability.
284func (p *Pusher) fullURL() string {
285	urlComponents := []string{}
286	if encodedJob, base64 := encodeComponent(p.job); base64 {
287		urlComponents = append(urlComponents, "job"+base64Suffix, encodedJob)
288	} else {
289		urlComponents = append(urlComponents, "job", encodedJob)
290	}
291	for ln, lv := range p.grouping {
292		if encodedLV, base64 := encodeComponent(lv); base64 {
293			urlComponents = append(urlComponents, ln+base64Suffix, encodedLV)
294		} else {
295			urlComponents = append(urlComponents, ln, encodedLV)
296		}
297	}
298	return fmt.Sprintf("%s/metrics/%s", p.url, strings.Join(urlComponents, "/"))
299}
300
301// encodeComponent encodes the provided string with base64.RawURLEncoding in
302// case it contains '/'. If not, it uses url.QueryEscape instead. It returns
303// true in the former case.
304func encodeComponent(s string) (string, bool) {
305	if strings.Contains(s, "/") {
306		return base64.RawURLEncoding.EncodeToString([]byte(s)), true
307	}
308	return url.QueryEscape(s), false
309}
310