github.com/yankunsam/loki/v2@v2.6.3-0.20220817130409-389df5235c27/clients/pkg/promtail/targets/heroku/target_test.go (about)

     1  package heroku
     2  
     3  import (
     4  	"flag"
     5  	"fmt"
     6  	"net"
     7  	"net/http"
     8  	"os"
     9  	"strings"
    10  	"testing"
    11  	"time"
    12  
    13  	"github.com/go-kit/log"
    14  	"github.com/google/uuid"
    15  	"github.com/prometheus/client_golang/prometheus"
    16  	"github.com/prometheus/common/model"
    17  	"github.com/prometheus/prometheus/model/relabel"
    18  	"github.com/stretchr/testify/require"
    19  	"github.com/weaveworks/common/server"
    20  
    21  	lokiClient "github.com/grafana/loki/clients/pkg/promtail/client"
    22  	"github.com/grafana/loki/clients/pkg/promtail/client/fake"
    23  	"github.com/grafana/loki/clients/pkg/promtail/scrapeconfig"
    24  )
    25  
    26  const localhost = "127.0.0.1"
    27  
    28  const testPayload = `270 <158>1 2022-06-13T14:52:23.622778+00:00 host heroku router - at=info method=GET path="/" host=cryptic-cliffs-27764.herokuapp.com request_id=59da6323-2bc4-4143-8677-cc66ccfb115f fwd="181.167.87.140" dyno=web.1 connect=0ms service=3ms status=200 bytes=6979 protocol=https
    29  `
    30  const testLogLine1 = `140 <190>1 2022-06-13T14:52:23.621815+00:00 host app web.1 - [GIN] 2022/06/13 - 14:52:23 | 200 |    1.428101ms |  181.167.87.140 | GET      "/"
    31  `
    32  const testLogLine1Timestamp = "2022-06-13T14:52:23.621815+00:00"
    33  const testLogLine2 = `156 <190>1 2022-06-13T14:52:23.827271+00:00 host app web.1 - [GIN] 2022/06/13 - 14:52:23 | 200 |      163.92µs |  181.167.87.140 | GET      "/static/main.css"
    34  `
    35  
    36  func makeDrainRequest(host string, bodies ...string) (*http.Request, error) {
    37  	req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/heroku/api/v1/drain", host), strings.NewReader(strings.Join(bodies, "")))
    38  	if err != nil {
    39  		return nil, err
    40  	}
    41  
    42  	drainToken := uuid.New().String()
    43  	frameID := uuid.New().String()
    44  	req.Header.Set("Content-Type", "application/heroku_drain-1")
    45  	req.Header.Set("Logplex-Drain-Token", fmt.Sprintf("d.%s", drainToken))
    46  	req.Header.Set("Logplex-Frame-Id", frameID)
    47  	req.Header.Set("Logplex-Msg-Count", fmt.Sprintf("%d", len(bodies)))
    48  
    49  	return req, nil
    50  }
    51  
    52  func TestHerokuDrainTarget(t *testing.T) {
    53  	w := log.NewSyncWriter(os.Stderr)
    54  	logger := log.NewLogfmtLogger(w)
    55  
    56  	type expectedEntry struct {
    57  		labels model.LabelSet
    58  		line   string
    59  	}
    60  	type args struct {
    61  		RequestBodies  []string
    62  		RelabelConfigs []*relabel.Config
    63  		Labels         model.LabelSet
    64  	}
    65  
    66  	cases := map[string]struct {
    67  		args            args
    68  		expectedEntries []expectedEntry
    69  	}{
    70  		"heroku request with a single log line, internal labels dropped, and fixed are propagated": {
    71  			args: args{
    72  				RequestBodies: []string{testPayload},
    73  				Labels: model.LabelSet{
    74  					"job": "some_job_name",
    75  				},
    76  			},
    77  			expectedEntries: []expectedEntry{
    78  				{
    79  					labels: model.LabelSet{
    80  						"job": "some_job_name",
    81  					},
    82  					line: `at=info method=GET path="/" host=cryptic-cliffs-27764.herokuapp.com request_id=59da6323-2bc4-4143-8677-cc66ccfb115f fwd="181.167.87.140" dyno=web.1 connect=0ms service=3ms status=200 bytes=6979 protocol=https
    83  `,
    84  				},
    85  			},
    86  		},
    87  		"heroku request with a two log lines, internal labels dropped, and fixed are propagated": {
    88  			args: args{
    89  				RequestBodies: []string{testLogLine1, testLogLine2},
    90  				Labels: model.LabelSet{
    91  					"job": "multiple_line_job",
    92  				},
    93  			},
    94  			expectedEntries: []expectedEntry{
    95  				{
    96  					labels: model.LabelSet{
    97  						"job": "multiple_line_job",
    98  					},
    99  					line: `[GIN] 2022/06/13 - 14:52:23 | 200 |    1.428101ms |  181.167.87.140 | GET      "/"
   100  `,
   101  				},
   102  				{
   103  					labels: model.LabelSet{
   104  						"job": "multiple_line_job",
   105  					},
   106  					line: `[GIN] 2022/06/13 - 14:52:23 | 200 |      163.92µs |  181.167.87.140 | GET      "/static/main.css"
   107  `,
   108  				},
   109  			},
   110  		},
   111  		"heroku request with a single log line, with internal labels relabeled, and fixed labels": {
   112  			args: args{
   113  				RequestBodies: []string{testLogLine1},
   114  				Labels: model.LabelSet{
   115  					"job": "relabeling_job",
   116  				},
   117  				RelabelConfigs: []*relabel.Config{
   118  					{
   119  						SourceLabels: model.LabelNames{"__heroku_drain_host"},
   120  						TargetLabel:  "host",
   121  						Replacement:  "$1",
   122  						Action:       relabel.Replace,
   123  						Regex:        relabel.MustNewRegexp("(.*)"),
   124  					},
   125  					{
   126  						SourceLabels: model.LabelNames{"__heroku_drain_app"},
   127  						TargetLabel:  "app",
   128  						Replacement:  "$1",
   129  						Action:       relabel.Replace,
   130  						Regex:        relabel.MustNewRegexp("(.*)"),
   131  					},
   132  					{
   133  						SourceLabels: model.LabelNames{"__heroku_drain_proc"},
   134  						TargetLabel:  "procID",
   135  						Replacement:  "$1",
   136  						Action:       relabel.Replace,
   137  						Regex:        relabel.MustNewRegexp("(.*)"),
   138  					},
   139  				},
   140  			},
   141  			expectedEntries: []expectedEntry{
   142  				{
   143  					line: `[GIN] 2022/06/13 - 14:52:23 | 200 |    1.428101ms |  181.167.87.140 | GET      "/"
   144  `,
   145  					labels: model.LabelSet{
   146  						"host":   "host",
   147  						"app":    "app",
   148  						"procID": "web.1",
   149  					},
   150  				},
   151  			},
   152  		},
   153  	}
   154  	for name, tc := range cases {
   155  		t.Run(name, func(t *testing.T) {
   156  			// Create fake promtail client
   157  			eh := fake.New(func() {})
   158  			defer eh.Stop()
   159  
   160  			serverConfig, port, err := getServerConfigWithAvailablePort()
   161  			require.NoError(t, err, "error generating server config or finding open port")
   162  			config := &scrapeconfig.HerokuDrainTargetConfig{
   163  				Server:               serverConfig,
   164  				Labels:               tc.args.Labels,
   165  				UseIncomingTimestamp: false,
   166  			}
   167  
   168  			prometheus.DefaultRegisterer = prometheus.NewRegistry()
   169  			metrics := NewMetrics(prometheus.DefaultRegisterer)
   170  			pt, err := NewTarget(metrics, logger, eh, "test_job", config, tc.args.RelabelConfigs)
   171  			require.NoError(t, err)
   172  			defer func() {
   173  				_ = pt.Stop()
   174  			}()
   175  
   176  			// Clear received lines after test case is ran
   177  			defer eh.Clear()
   178  
   179  			// Send some logs
   180  			ts := time.Now()
   181  
   182  			req, err := makeDrainRequest(fmt.Sprintf("http://%s:%d", localhost, port), tc.args.RequestBodies...)
   183  			require.NoError(t, err, "expected test drain request to be successfully created")
   184  			res, err := http.DefaultClient.Do(req)
   185  			require.NoError(t, err)
   186  			require.Equal(t, http.StatusNoContent, res.StatusCode, "expected no-content status code")
   187  
   188  			waitForMessages(eh)
   189  
   190  			// Make sure we didn't timeout
   191  			require.Equal(t, len(tc.args.RequestBodies), len(eh.Received()))
   192  
   193  			require.Equal(t, len(eh.Received()), len(tc.expectedEntries), "expected to receive equal amount of expected label sets")
   194  			for i, expectedEntry := range tc.expectedEntries {
   195  				// TODO: Add assertion over propagated timestamp
   196  				actualEntry := eh.Received()[i]
   197  
   198  				require.Equal(t, expectedEntry.line, actualEntry.Line, "expected line to be equal for %d-th entry", i)
   199  
   200  				expectedLS := expectedEntry.labels
   201  				actualLS := actualEntry.Labels
   202  				for label, value := range expectedLS {
   203  					require.Equal(t, expectedLS[label], actualLS[label], "expected label %s to be equal to %s in %d-th entry", label, value, i)
   204  				}
   205  
   206  				// Timestamp is always set in the handler, we expect received timestamps to be slightly higher than the timestamp when we started sending logs.
   207  				require.GreaterOrEqual(t, actualEntry.Timestamp.Unix(), ts.Unix(), "expected %d-th entry to have a received timestamp greater than publish time", i)
   208  			}
   209  		})
   210  	}
   211  }
   212  
   213  func TestHerokuDrainTarget_UseIncomingTimestamp(t *testing.T) {
   214  	w := log.NewSyncWriter(os.Stderr)
   215  	logger := log.NewLogfmtLogger(w)
   216  
   217  	// Create fake promtail client
   218  	eh := fake.New(func() {})
   219  	defer eh.Stop()
   220  
   221  	serverConfig, port, err := getServerConfigWithAvailablePort()
   222  	require.NoError(t, err, "error generating server config or finding open port")
   223  	config := &scrapeconfig.HerokuDrainTargetConfig{
   224  		Server:               serverConfig,
   225  		Labels:               nil,
   226  		UseIncomingTimestamp: true,
   227  	}
   228  
   229  	prometheus.DefaultRegisterer = prometheus.NewRegistry()
   230  	metrics := NewMetrics(prometheus.DefaultRegisterer)
   231  	pt, err := NewTarget(metrics, logger, eh, "test_job", config, nil)
   232  	require.NoError(t, err)
   233  	defer func() {
   234  		_ = pt.Stop()
   235  	}()
   236  
   237  	// Clear received lines after test case is ran
   238  	defer eh.Clear()
   239  
   240  	req, err := makeDrainRequest(fmt.Sprintf("http://%s:%d", localhost, port), testLogLine1)
   241  	require.NoError(t, err, "expected test drain request to be successfully created")
   242  	res, err := http.DefaultClient.Do(req)
   243  	require.NoError(t, err)
   244  	require.Equal(t, http.StatusNoContent, res.StatusCode, "expected no-content status code")
   245  
   246  	waitForMessages(eh)
   247  
   248  	// Make sure we didn't timeout
   249  	require.Equal(t, 1, len(eh.Received()))
   250  
   251  	expectedTs, err := time.Parse(time.RFC3339Nano, testLogLine1Timestamp)
   252  	require.NoError(t, err, "expected expected timestamp to be parse correctly")
   253  	require.Equal(t, expectedTs, eh.Received()[0].Timestamp, "expected entry timestamp to be overridden by received one")
   254  }
   255  
   256  func TestHerokuDrainTarget_ErrorOnNotPrometheusCompatibleJobName(t *testing.T) {
   257  	w := log.NewSyncWriter(os.Stderr)
   258  	logger := log.NewLogfmtLogger(w)
   259  
   260  	// Create fake promtail client
   261  	eh := fake.New(func() {})
   262  	defer eh.Stop()
   263  
   264  	serverConfig, _, err := getServerConfigWithAvailablePort()
   265  	require.NoError(t, err, "error generating server config or finding open port")
   266  	config := &scrapeconfig.HerokuDrainTargetConfig{
   267  		Server:               serverConfig,
   268  		Labels:               nil,
   269  		UseIncomingTimestamp: true,
   270  	}
   271  
   272  	prometheus.DefaultRegisterer = prometheus.NewRegistry()
   273  	metrics := NewMetrics(prometheus.DefaultRegisterer)
   274  	pt, err := NewTarget(metrics, logger, eh, "test-job", config, nil)
   275  	require.Error(t, err, "expected an error from creating a heroku target with an invalid job name")
   276  	// Cleanup target in the case test failed and target started correctly
   277  	if err == nil {
   278  		_ = pt.Stop()
   279  	}
   280  }
   281  
   282  func TestHerokuDrainTarget_UseTenantIDHeaderIfPresent(t *testing.T) {
   283  	w := log.NewSyncWriter(os.Stderr)
   284  	logger := log.NewLogfmtLogger(w)
   285  
   286  	// Create fake promtail client
   287  	eh := fake.New(func() {})
   288  	defer eh.Stop()
   289  
   290  	serverConfig, port, err := getServerConfigWithAvailablePort()
   291  	require.NoError(t, err, "error generating server config or finding open port")
   292  	config := &scrapeconfig.HerokuDrainTargetConfig{
   293  		Server:               serverConfig,
   294  		Labels:               nil,
   295  		UseIncomingTimestamp: true,
   296  	}
   297  
   298  	prometheus.DefaultRegisterer = prometheus.NewRegistry()
   299  	metrics := NewMetrics(prometheus.DefaultRegisterer)
   300  	pt, err := NewTarget(metrics, logger, eh, "test_job", config, nil)
   301  	require.NoError(t, err)
   302  	defer func() {
   303  		_ = pt.Stop()
   304  	}()
   305  
   306  	// Clear received lines after test case is ran
   307  	defer eh.Clear()
   308  
   309  	req, err := makeDrainRequest(fmt.Sprintf("http://%s:%d", localhost, port), testLogLine1)
   310  	require.NoError(t, err, "expected test drain request to be successfully created")
   311  	req.Header.Set("X-Scope-OrgID", "42")
   312  	res, err := http.DefaultClient.Do(req)
   313  	require.NoError(t, err)
   314  	require.Equal(t, http.StatusNoContent, res.StatusCode, "expected no-content status code")
   315  
   316  	waitForMessages(eh)
   317  
   318  	// Make sure we didn't timeout
   319  	require.Equal(t, 1, len(eh.Received()))
   320  
   321  	require.Equal(t, model.LabelValue("42"), eh.Received()[0].Labels[lokiClient.ReservedLabelTenantID])
   322  }
   323  
   324  func waitForMessages(eh *fake.Client) {
   325  	countdown := 1000
   326  	for len(eh.Received()) != 1 && countdown > 0 {
   327  		time.Sleep(1 * time.Millisecond)
   328  		countdown--
   329  	}
   330  }
   331  
   332  func getServerConfigWithAvailablePort() (cfg server.Config, port int, err error) {
   333  	// Get a randomly available port by open and closing a TCP socket
   334  	addr, err := net.ResolveTCPAddr("tcp", localhost+":0")
   335  	if err != nil {
   336  		return
   337  	}
   338  	l, err := net.ListenTCP("tcp", addr)
   339  	if err != nil {
   340  		return
   341  	}
   342  	port = l.Addr().(*net.TCPAddr).Port
   343  	err = l.Close()
   344  	if err != nil {
   345  		return
   346  	}
   347  
   348  	// Adjust some of the defaults
   349  	cfg.RegisterFlags(flag.NewFlagSet("empty", flag.ContinueOnError))
   350  	cfg.HTTPListenAddress = localhost
   351  	cfg.HTTPListenPort = port
   352  	cfg.GRPCListenAddress = localhost
   353  	cfg.GRPCListenPort = 0 // Not testing GRPC, a random port will be assigned
   354  
   355  	return
   356  }