-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathapi.go
256 lines (216 loc) · 7.99 KB
/
api.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
package main
/*
Portions of this file were derived from Dave Cheney's httpstat:
https://github.com/davecheney/httpstat
His code is licensed as follows:
MIT License
Copyright (c) 2016 Dave Cheney
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/http/httptrace"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
var placeholderRegex = regexp.MustCompile(`{{ *[^}}]* *}}`)
// APIJobConfig holds the configuration for an API job
type APIJobConfig struct {
Steps []JobStep
Interval uint16 `yaml:"interval"`
Tags map[string]string `yaml:"tags,omitempty"`
}
// GetJobName returns the name of the job
func (c *APIJobConfig) GetJobName() string {
return c.Steps[0].Name
}
// APIJob holds the runtime configuration for an API job
type APIJob struct {
config APIJobConfig
wg *sync.WaitGroup
ctx context.Context
storage *Storage
client *http.Client
}
// StartJob starts an API job
func (j *APIJob) StartJob() {
j.wg.Add(1)
defer j.wg.Done()
log.Println("Starting job", j.config.Steps[0].Name)
jobTicker := time.NewTicker(time.Duration(j.config.Interval) * time.Second)
for {
select {
case <-jobTicker.C:
go j.RunAPITest()
case <-j.ctx.Done():
log.Println("Cancellation request received. Cancelling job runner.")
return
}
}
}
// RunAPITest starts an HTTP/HTTPS API test of a site within crabby. It uses Go's built-in net/http client.
func (j *APIJob) RunAPITest() {
responses := map[string]json.RawMessage{}
for i := range j.config.Steps {
j.runAPITestStep(i, responses)
}
}
func (j *APIJob) runAPITestStep(stepNum int, responses map[string]json.RawMessage) {
step := j.config.Steps[stepNum]
var method = strings.ToUpper(step.Method)
if method == "" {
method = http.MethodGet
}
body, err := replacePlaceholders(step.Body, responses)
if err != nil {
log.Printf("unable to substitute body variables in body: %v", err)
return
}
req, err := http.NewRequest(method, step.URL, strings.NewReader(body))
if err != nil {
log.Printf("unable to create request: %v", err)
return
}
if err := addHeaders(req, step, responses); err != nil {
log.Printf("unable to process headers: %v", err)
return
}
var t0, t1, t2, t3, t4 time.Time
trace := &httptrace.ClientTrace{
DNSStart: func(_ httptrace.DNSStartInfo) { t0 = time.Now() },
DNSDone: func(_ httptrace.DNSDoneInfo) { t1 = time.Now() },
ConnectStart: func(_, _ string) {
if t1.IsZero() {
// connecting to IP
t1 = time.Now()
}
},
ConnectDone: func(net, addr string, err error) {
if err != nil {
log.Printf("unable to connect to host %v: %v", addr, err)
}
t2 = time.Now()
},
GotConn: func(_ httptrace.GotConnInfo) { t3 = time.Now() },
GotFirstResponseByte: func() { t4 = time.Now() },
}
// We'll use our Context in this request in case we have to shut down midstream
req = req.WithContext(httptrace.WithClientTrace(j.ctx, trace))
resp, err := j.client.Do(req)
if err != nil {
log.Println("Failed to read response:", err)
return
}
// Send our server response code as an event
j.storage.EventDistributor <- makeEvent(step.Name, resp.StatusCode, step.Tags)
// unmarshal response and save into map
if responses[step.Name], err = ioutil.ReadAll(resp.Body); err != nil {
log.Println("error: could not read response body:", err)
}
// Even though we never read the response body, if we don't close it,
// the http.Transport goroutines will terminate and the app will eventually
// crash due to OOM
resp.Body.Close()
t5 := time.Now() // after read body
if t0.IsZero() {
// we skipped DNS
t0 = t1
}
url, err := url.Parse(step.URL)
if err != nil {
log.Println("Failed to parse URL:", err)
return
}
switch url.Scheme {
case "https":
j.storage.MetricDistributor <- j.makeAPIMetric(step, "dns_duration_milliseconds", t1.Sub(t0).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_connection_duration_milliseconds", t2.Sub(t1).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "tls_handshake_duration_milliseconds", t3.Sub(t2).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_processing_duration_milliseconds", t4.Sub(t3).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_response_duration_milliseconds", t5.Sub(t4).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "time_to_first_byte_milliseconds", t4.Sub(t0).Seconds()*1000)
case "http":
j.storage.MetricDistributor <- j.makeAPIMetric(step, "dns_duration_milliseconds", t1.Sub(t0).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_connection_duration_milliseconds", t3.Sub(t1).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_processing_duration_milliseconds", t4.Sub(t3).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "server_response_duration_milliseconds", t5.Sub(t4).Seconds()*1000)
j.storage.MetricDistributor <- j.makeAPIMetric(step, "time_to_first_byte_milliseconds", t4.Sub(t0).Seconds()*1000)
}
}
func addHeaders(req *http.Request, j JobStep, responses map[string]json.RawMessage) error {
req.Header = http.Header{}
for key, value := range j.Header {
req.Header.Add(key, value)
}
if j.ContentType != "" {
req.Header["Content-Type"] = []string{j.ContentType}
}
// replace placeholders in header values
for key := range req.Header {
for i := range req.Header[key] {
var err error
if req.Header[key][i], err = replacePlaceholders(req.Header[key][i], responses); err != nil {
return err
}
}
}
return nil
}
// getResponseValue looks at the responses of previous steps for a response value.
// s should be <stepName>.objectKey1.objectkey2...
// e.g. step1 returns a json { "key": { "subkey": "value" } }.
// step2 can access this by putting {{ step1.key.subkey }} to obtain "value"
// Note: this function will fail if the key contains "." e.g. { "bad.key": value }
func getResponseValue(s string, m map[string]json.RawMessage) (string, error) {
split := strings.SplitN(s, ".", 2)
value := m[split[0]]
if len(split) == 1 {
return string(value), nil
}
var submap map[string]json.RawMessage
if err := json.Unmarshal(value, &submap); err != nil {
return "", err
}
return getResponseValue(split[1], submap)
}
func replacePlaceholders(s string, m map[string]json.RawMessage) (string, error) {
vars := placeholderRegex.FindAll([]byte(s), -1)
varvals := make([]interface{}, len(vars))
for i, v := range vars {
key := string(v)
key = strings.TrimPrefix(key, "{{")
key = strings.TrimSuffix(key, "}}")
key = strings.TrimSpace(key)
var err error
if varvals[i], err = getResponseValue(key, m); err != nil {
return "", err
}
}
format := placeholderRegex.ReplaceAll([]byte(s), []byte("%s"))
return fmt.Sprintf(string(format), varvals...), nil
}
func (j *APIJob) makeAPIMetric(js JobStep, metric string, value float64) Metric {
return makeMetric(metric, value, js.Name, js.URL, j.config.Tags)
}