github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/pkg/agent/upstream/remote/remote.go (about)

     1  package remote
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"github.com/pyroscope-io/pyroscope/pkg/agent/log"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"path"
    12  	"runtime/debug"
    13  	"strconv"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	"github.com/pyroscope-io/pyroscope/pkg/agent/upstream"
    19  )
    20  
    21  var (
    22  	ErrCloudTokenRequired = errors.New("Please provide an authentication token. You can find it here: https://pyroscope.io/cloud")
    23  	cloudHostnameSuffix   = "pyroscope.cloud"
    24  )
    25  
    26  type Remote struct {
    27  	cfg    RemoteConfig
    28  	jobs   chan *upstream.UploadJob
    29  	client *http.Client
    30  	Logger log.Logger
    31  
    32  	done chan struct{}
    33  	wg   sync.WaitGroup
    34  }
    35  
    36  type RemoteConfig struct {
    37  	AuthToken              string
    38  	BasicAuthUser          string
    39  	BasicAuthPassword      string
    40  	TenantID               string
    41  	HTTPHeaders            map[string]string
    42  	UpstreamThreads        int
    43  	UpstreamAddress        string
    44  	UpstreamRequestTimeout time.Duration
    45  }
    46  
    47  func New(cfg RemoteConfig, logger log.Logger) (*Remote, error) {
    48  	remote := &Remote{
    49  		cfg:  cfg,
    50  		jobs: make(chan *upstream.UploadJob, 100),
    51  		client: &http.Client{
    52  			Transport: &http.Transport{
    53  				MaxConnsPerHost: cfg.UpstreamThreads,
    54  			},
    55  			Timeout: cfg.UpstreamRequestTimeout,
    56  		},
    57  		Logger: logger,
    58  		done:   make(chan struct{}),
    59  	}
    60  
    61  	// parse the upstream address
    62  	u, err := url.Parse(cfg.UpstreamAddress)
    63  	if err != nil {
    64  		return nil, err
    65  	}
    66  
    67  	// authorize the token first
    68  	if cfg.AuthToken == "" && requiresAuthToken(u) {
    69  		return nil, ErrCloudTokenRequired
    70  	}
    71  
    72  	return remote, nil
    73  }
    74  
    75  func (r *Remote) Start() {
    76  	for i := 0; i < r.cfg.UpstreamThreads; i++ {
    77  		go r.handleJobs()
    78  	}
    79  }
    80  
    81  func (r *Remote) Stop() {
    82  	if r.done != nil {
    83  		close(r.done)
    84  	}
    85  
    86  	// wait for uploading goroutines exit
    87  	r.wg.Wait()
    88  }
    89  
    90  func (r *Remote) Upload(job *upstream.UploadJob) {
    91  	select {
    92  	case r.jobs <- job:
    93  	default:
    94  		r.Logger.Errorf("remote upload queue is full, dropping a profile job")
    95  	}
    96  }
    97  
    98  // UploadSync is only used in benchmarks right now
    99  func (r *Remote) UploadSync(job *upstream.UploadJob) error {
   100  	return r.uploadProfile(job)
   101  }
   102  
   103  func (r *Remote) uploadProfile(j *upstream.UploadJob) error {
   104  	u, err := url.Parse(r.cfg.UpstreamAddress)
   105  	if err != nil {
   106  		return fmt.Errorf("url parse: %v", err)
   107  	}
   108  
   109  	q := u.Query()
   110  	q.Set("name", j.Name)
   111  	// TODO: I think these should be renamed to startTime / endTime
   112  	q.Set("from", strconv.Itoa(int(j.StartTime.Unix())))
   113  	q.Set("until", strconv.Itoa(int(j.EndTime.Unix())))
   114  	q.Set("spyName", j.SpyName)
   115  	q.Set("sampleRate", strconv.Itoa(int(j.SampleRate)))
   116  	q.Set("units", string(j.Units))
   117  	q.Set("aggregationType", string(j.AggregationType))
   118  
   119  	u.Path = path.Join(u.Path, "ingest")
   120  	u.RawQuery = q.Encode()
   121  
   122  	r.Logger.Debugf("uploading at %s", u.String())
   123  	// new a request for the job
   124  	request, err := http.NewRequest("POST", u.String(), bytes.NewReader(j.Trie.Bytes()))
   125  	if err != nil {
   126  		return fmt.Errorf("new http request: %v", err)
   127  	}
   128  	request.Header.Set("Content-Type", "binary/octet-stream+trie")
   129  
   130  	if r.cfg.AuthToken != "" {
   131  		request.Header.Set("Authorization", "Bearer "+r.cfg.AuthToken)
   132  	} else if r.cfg.BasicAuthUser != "" && r.cfg.BasicAuthPassword != "" {
   133  		request.SetBasicAuth(r.cfg.BasicAuthUser, r.cfg.BasicAuthPassword)
   134  	}
   135  	if r.cfg.TenantID != "" {
   136  		request.Header.Set("X-Scope-OrgID", r.cfg.TenantID)
   137  	}
   138  	for k, v := range r.cfg.HTTPHeaders {
   139  		request.Header.Set(k, v)
   140  	}
   141  
   142  	// do the request and get the response
   143  	response, err := r.client.Do(request)
   144  	if err != nil {
   145  		return fmt.Errorf("do http request: %v", err)
   146  	}
   147  	defer response.Body.Close()
   148  
   149  	// read all the response body
   150  	respBody, err := io.ReadAll(response.Body)
   151  	if err != nil {
   152  		return fmt.Errorf("read response body: %v", err)
   153  	}
   154  
   155  	if response.StatusCode != 200 {
   156  		return fmt.Errorf("failed to upload. server responded with statusCode: '%d' and body: '%s'", response.StatusCode, string(respBody))
   157  	}
   158  
   159  	return nil
   160  }
   161  
   162  // handle the jobs
   163  func (r *Remote) handleJobs() {
   164  	for {
   165  		select {
   166  		case <-r.done:
   167  			return
   168  		case job := <-r.jobs:
   169  			r.safeUpload(job)
   170  		}
   171  	}
   172  }
   173  
   174  func requiresAuthToken(u *url.URL) bool {
   175  	return strings.HasSuffix(u.Host, cloudHostnameSuffix)
   176  }
   177  
   178  // do safe upload
   179  func (r *Remote) safeUpload(job *upstream.UploadJob) {
   180  	defer func() {
   181  		if catch := recover(); catch != nil {
   182  			r.Logger.Errorf("recover stack: %v", debug.Stack())
   183  		}
   184  	}()
   185  
   186  	// update the profile data to server
   187  	if err := r.uploadProfile(job); err != nil {
   188  		r.Logger.Errorf("upload profile: %v", err)
   189  	}
   190  }