github.com/lulzWill/go-agent@v2.1.2+incompatible/internal/collector.go (about) 1 package internal 2 3 import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "net/url" 10 "os" 11 "regexp" 12 13 "github.com/lulzWill/go-agent/internal/logger" 14 ) 15 16 const ( 17 procotolVersion = "16" 18 userAgentPrefix = "NewRelic-Go-Agent/" 19 20 // Methods used in collector communication. 21 cmdPreconnect = "preconnect" 22 cmdConnect = "connect" 23 cmdMetrics = "metric_data" 24 cmdCustomEvents = "custom_event_data" 25 cmdTxnEvents = "analytic_event_data" 26 cmdErrorEvents = "error_event_data" 27 cmdErrorData = "error_data" 28 cmdTxnTraces = "transaction_sample_data" 29 cmdSlowSQLs = "sql_trace_data" 30 ) 31 32 var ( 33 // ErrPayloadTooLarge is created in response to receiving a 413 response 34 // code. 35 ErrPayloadTooLarge = errors.New("payload too large") 36 // ErrUnauthorized is created in response to receiving a 401 response code. 37 ErrUnauthorized = errors.New("unauthorized") 38 // ErrUnsupportedMedia is created in response to receiving a 415 39 // response code. 40 ErrUnsupportedMedia = errors.New("unsupported media") 41 ) 42 43 // RpmCmd contains fields specific to an individual call made to RPM. 44 type RpmCmd struct { 45 Name string 46 Collector string 47 RunID string 48 Data []byte 49 } 50 51 // RpmControls contains fields which will be the same for all calls made 52 // by the same application. 53 type RpmControls struct { 54 License string 55 Client *http.Client 56 Logger logger.Logger 57 AgentVersion string 58 } 59 60 func rpmURL(cmd RpmCmd, cs RpmControls) string { 61 var u url.URL 62 63 u.Host = cmd.Collector 64 u.Path = "agent_listener/invoke_raw_method" 65 u.Scheme = "https" 66 67 query := url.Values{} 68 query.Set("marshal_format", "json") 69 query.Set("protocol_version", procotolVersion) 70 query.Set("method", cmd.Name) 71 query.Set("license_key", cs.License) 72 73 if len(cmd.RunID) > 0 { 74 query.Set("run_id", cmd.RunID) 75 } 76 77 u.RawQuery = query.Encode() 78 return u.String() 79 } 80 81 type unexpectedStatusCodeErr struct { 82 code int 83 } 84 85 func (e unexpectedStatusCodeErr) Error() string { 86 return fmt.Sprintf("unexpected HTTP status code: %d", e.code) 87 } 88 89 func collectorRequestInternal(url string, data []byte, cs RpmControls) ([]byte, error) { 90 deflated, err := compress(data) 91 if nil != err { 92 return nil, err 93 } 94 95 req, err := http.NewRequest("POST", url, deflated) 96 if nil != err { 97 return nil, err 98 } 99 100 req.Header.Add("Accept-Encoding", "identity, deflate") 101 req.Header.Add("Content-Type", "application/octet-stream") 102 req.Header.Add("User-Agent", userAgentPrefix+cs.AgentVersion) 103 req.Header.Add("Content-Encoding", "deflate") 104 105 resp, err := cs.Client.Do(req) 106 if err != nil { 107 return nil, err 108 } 109 110 defer resp.Body.Close() 111 112 switch resp.StatusCode { 113 case 200: 114 // Nothing to do. 115 case 401: 116 return nil, ErrUnauthorized 117 case 413: 118 return nil, ErrPayloadTooLarge 119 case 415: 120 return nil, ErrUnsupportedMedia 121 default: 122 // If the response code is not 200, then the collector may not return 123 // valid JSON. 124 return nil, unexpectedStatusCodeErr{code: resp.StatusCode} 125 } 126 127 // Read the entire response, rather than using resp.Body as input to json.NewDecoder to 128 // avoid the issue described here: 129 // https://github.com/google/go-github/pull/317 130 // https://ahmetalpbalkan.com/blog/golang-json-decoder-pitfalls/ 131 // Also, collector JSON responses are expected to be quite small. 132 b, err := ioutil.ReadAll(resp.Body) 133 if nil != err { 134 return nil, err 135 } 136 return parseResponse(b) 137 } 138 139 // CollectorRequest makes a request to New Relic. 140 func CollectorRequest(cmd RpmCmd, cs RpmControls) ([]byte, error) { 141 url := rpmURL(cmd, cs) 142 143 if cs.Logger.DebugEnabled() { 144 cs.Logger.Debug("rpm request", map[string]interface{}{ 145 "command": cmd.Name, 146 "url": url, 147 "payload": JSONString(cmd.Data), 148 }) 149 } 150 151 resp, err := collectorRequestInternal(url, cmd.Data, cs) 152 if err != nil { 153 cs.Logger.Debug("rpm failure", map[string]interface{}{ 154 "command": cmd.Name, 155 "url": url, 156 "error": err.Error(), 157 }) 158 } 159 160 if cs.Logger.DebugEnabled() { 161 cs.Logger.Debug("rpm response", map[string]interface{}{ 162 "command": cmd.Name, 163 "url": url, 164 "response": JSONString(resp), 165 }) 166 } 167 168 return resp, err 169 } 170 171 type rpmException struct { 172 Message string `json:"message"` 173 ErrorType string `json:"error_type"` 174 } 175 176 func (e *rpmException) Error() string { 177 return fmt.Sprintf("%s: %s", e.ErrorType, e.Message) 178 } 179 180 func hasType(e error, expected string) bool { 181 rpmErr, ok := e.(*rpmException) 182 if !ok { 183 return false 184 } 185 return rpmErr.ErrorType == expected 186 187 } 188 189 const ( 190 forceRestartType = "NewRelic::Agent::ForceRestartException" 191 disconnectType = "NewRelic::Agent::ForceDisconnectException" 192 licenseInvalidType = "NewRelic::Agent::LicenseException" 193 runtimeType = "RuntimeError" 194 ) 195 196 // IsRestartException indicates if the error was a restart exception. 197 func IsRestartException(e error) bool { return hasType(e, forceRestartType) } 198 199 // IsLicenseException indicates if the error was an invalid exception. 200 func IsLicenseException(e error) bool { return hasType(e, licenseInvalidType) } 201 202 // IsRuntime indicates if the error was a runtime exception. 203 func IsRuntime(e error) bool { return hasType(e, runtimeType) } 204 205 // IsDisconnect indicates if the error was a disconnect exception. 206 func IsDisconnect(e error) bool { 207 // Unrecognized or missing security policies should be treated as 208 // disconnects. 209 if _, ok := e.(errUnknownRequiredPolicy); ok { 210 return true 211 } 212 if _, ok := e.(errUnsetPolicy); ok { 213 return true 214 } 215 return hasType(e, disconnectType) 216 } 217 218 func parseResponse(b []byte) ([]byte, error) { 219 var r struct { 220 ReturnValue json.RawMessage `json:"return_value"` 221 Exception *rpmException `json:"exception"` 222 } 223 224 err := json.Unmarshal(b, &r) 225 if nil != err { 226 return nil, err 227 } 228 229 if nil != r.Exception { 230 return nil, r.Exception 231 } 232 233 return r.ReturnValue, nil 234 } 235 236 const ( 237 // NEW_RELIC_HOST can be used to override the New Relic endpoint. This 238 // is useful for testing. 239 envHost = "NEW_RELIC_HOST" 240 ) 241 242 var ( 243 preconnectHostOverride = os.Getenv(envHost) 244 preconnectHostDefault = "collector.newrelic.com" 245 preconnectRegionLicenseRegex = regexp.MustCompile(`(^.+?)x`) 246 ) 247 248 func calculatePreconnectHost(license, overrideHost string) string { 249 if "" != overrideHost { 250 return overrideHost 251 } 252 m := preconnectRegionLicenseRegex.FindStringSubmatch(license) 253 if len(m) > 1 { 254 return "collector." + m[1] + ".nr-data.net" 255 } 256 return preconnectHostDefault 257 } 258 259 // ConnectJSONCreator allows the creation of the connect payload JSON to be 260 // deferred until the SecurityPolicies are acquired and vetted. 261 type ConnectJSONCreator interface { 262 CreateConnectJSON(*SecurityPolicies) ([]byte, error) 263 } 264 265 type preconnectRequest struct { 266 SecurityPoliciesToken string `json:"security_policies_token,omitempty"` 267 } 268 269 // ConnectAttempt tries to connect an application. 270 func ConnectAttempt(config ConnectJSONCreator, securityPoliciesToken string, cs RpmControls) (*ConnectReply, error) { 271 preconnectData, err := json.Marshal([]preconnectRequest{ 272 preconnectRequest{SecurityPoliciesToken: securityPoliciesToken}, 273 }) 274 if nil != err { 275 return nil, fmt.Errorf("unable to marshal preconnect data: %v", err) 276 } 277 278 call := RpmCmd{ 279 Name: cmdPreconnect, 280 Collector: calculatePreconnectHost(cs.License, preconnectHostOverride), 281 Data: preconnectData, 282 } 283 284 out, err := CollectorRequest(call, cs) 285 if nil != err { 286 // err is intentionally unmodified: We do not want to change 287 // the type of these collector errors. 288 return nil, err 289 } 290 291 var preconnect PreconnectReply 292 err = json.Unmarshal(out, &preconnect) 293 if nil != err { 294 // Unknown policies detected during unmarshal should produce a 295 // disconnect. 296 if IsDisconnect(err) { 297 return nil, err 298 } 299 return nil, fmt.Errorf("unable to parse preconnect reply: %v", err) 300 } 301 302 js, err := config.CreateConnectJSON(preconnect.SecurityPolicies.PointerIfPopulated()) 303 if nil != err { 304 return nil, fmt.Errorf("unable to create connect data: %v", err) 305 } 306 307 call.Collector = preconnect.Collector 308 call.Data = js 309 call.Name = cmdConnect 310 311 rawReply, err := CollectorRequest(call, cs) 312 if nil != err { 313 // err is intentionally unmodified: We do not want to change 314 // the type of these collector errors. 315 return nil, err 316 } 317 318 reply := ConnectReplyDefaults() 319 err = json.Unmarshal(rawReply, reply) 320 if nil != err { 321 return nil, fmt.Errorf("unable to parse connect reply: %v", err) 322 } 323 // Note: This should never happen. It would mean the collector 324 // response is malformed. This exists merely as extra defensiveness. 325 if "" == reply.RunID { 326 return nil, errors.New("connect reply missing agent run id") 327 } 328 329 reply.PreconnectReply = preconnect 330 331 return reply, nil 332 }