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  }