mirror of https://github.com/grafana/loki
promtail: add multi-tenant support (#1135)
* promtail: added tenant_id support to promtail client * promtail: added tenant stage to dinamically override the tenant ID * promtail: documented client's batch struct and improved tenant stage config pointer usage * Added static value support to tenant stagepull/1239/head
parent
3d2c643bba
commit
04f58c880f
@ -0,0 +1,80 @@ |
||||
# `tenant` stage |
||||
|
||||
The tenant stage is an action stage that sets the tenant ID for the log entry |
||||
picking it from a field in the extracted data map. If the field is missing, the |
||||
default promtail client [`tenant_id`](../configuration.md#client_config) will |
||||
be used. |
||||
|
||||
|
||||
## Schema |
||||
|
||||
```yaml |
||||
tenant: |
||||
# Name from extracted data to whose value should be set as tenant ID. |
||||
# Either source or value config option is required, but not both (they |
||||
# are mutually exclusive). |
||||
[ source: <string> ] |
||||
|
||||
# Value to use to set the tenant ID when this stage is executed. Useful |
||||
# when this stage is included within a conditional pipeline with "match". |
||||
[ value: <string> ] |
||||
``` |
||||
|
||||
### Example: extract the tenant ID from a structured log |
||||
|
||||
For the given pipeline: |
||||
|
||||
```yaml |
||||
pipeline_stages: |
||||
- json: |
||||
expressions: |
||||
customer_id: customer_id |
||||
- tenant: |
||||
source: customer_id |
||||
``` |
||||
|
||||
Given the following log line: |
||||
|
||||
```json |
||||
{"customer_id":"1","log":"log message\n","stream":"stderr","time":"2019-04-30T02:12:41.8443515Z"} |
||||
``` |
||||
|
||||
The first stage would extract `customer_id` into the extracted map with a value of |
||||
`1`. The tenant stage would set the `X-Scope-OrgID` request header (used by Loki to |
||||
identify the tenant) to the value of the `customer_id` extracted data, which is `1`. |
||||
|
||||
|
||||
### Example: override the tenant ID with the configured value |
||||
|
||||
For the given pipeline: |
||||
|
||||
```yaml |
||||
pipeline_stages: |
||||
- json: |
||||
expressions: |
||||
app: |
||||
message: |
||||
- labels: |
||||
app: |
||||
- match: |
||||
selector: '{app="api"}' |
||||
stages: |
||||
- tenant: |
||||
value: "team-api" |
||||
- output: |
||||
source: message |
||||
``` |
||||
|
||||
Given the following log line: |
||||
|
||||
```json |
||||
{"app":"api","log":"log message\n","stream":"stderr","time":"2019-04-30T02:12:41.8443515Z"} |
||||
``` |
||||
|
||||
The pipeline would: |
||||
|
||||
1. Decode the JSON log |
||||
2. Set the label `app="api"` |
||||
3. Process the `match` stage checking if the `{app="api"}` selector matches |
||||
and - whenever it matches - run the sub stages. The `tenant` sub stage |
||||
would override the tenant with the value `"team-api"`. |
@ -0,0 +1,106 @@ |
||||
package stages |
||||
|
||||
import ( |
||||
"reflect" |
||||
"time" |
||||
|
||||
"github.com/go-kit/kit/log" |
||||
"github.com/go-kit/kit/log/level" |
||||
"github.com/grafana/loki/pkg/promtail/constants" |
||||
"github.com/mitchellh/mapstructure" |
||||
"github.com/pkg/errors" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
const ( |
||||
ErrTenantStageEmptySourceOrValue = "source or value config are required" |
||||
ErrTenantStageConflictingSourceAndValue = "source and value are mutually exclusive: you should set source or value but not both" |
||||
) |
||||
|
||||
type tenantStage struct { |
||||
cfg TenantConfig |
||||
logger log.Logger |
||||
} |
||||
|
||||
type TenantConfig struct { |
||||
Source string `mapstructure:"source"` |
||||
Value string `mapstructure:"value"` |
||||
} |
||||
|
||||
// validateTenantConfig validates the tenant stage configuration
|
||||
func validateTenantConfig(c TenantConfig) error { |
||||
if c.Source == "" && c.Value == "" { |
||||
return errors.New(ErrTenantStageEmptySourceOrValue) |
||||
} |
||||
|
||||
if c.Source != "" && c.Value != "" { |
||||
return errors.New(ErrTenantStageConflictingSourceAndValue) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// newTenantStage creates a new tenant stage to override the tenant ID from extracted data
|
||||
func newTenantStage(logger log.Logger, configs interface{}) (*tenantStage, error) { |
||||
cfg := TenantConfig{} |
||||
err := mapstructure.Decode(configs, &cfg) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
err = validateTenantConfig(cfg) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &tenantStage{ |
||||
cfg: cfg, |
||||
logger: logger, |
||||
}, nil |
||||
} |
||||
|
||||
// Process implements Stage
|
||||
func (s *tenantStage) Process(labels model.LabelSet, extracted map[string]interface{}, t *time.Time, entry *string) { |
||||
var tenantID string |
||||
|
||||
// Get tenant ID from source or configured value
|
||||
if s.cfg.Source != "" { |
||||
tenantID = s.getTenantFromSourceField(extracted) |
||||
} else { |
||||
tenantID = s.cfg.Value |
||||
} |
||||
|
||||
// Skip an empty tenant ID (ie. failed to get the tenant from the source)
|
||||
if tenantID == "" { |
||||
return |
||||
} |
||||
|
||||
labels[constants.ReservedLabelTenantID] = model.LabelValue(tenantID) |
||||
} |
||||
|
||||
// Name implements Stage
|
||||
func (s *tenantStage) Name() string { |
||||
return StageTypeTenant |
||||
} |
||||
|
||||
func (s *tenantStage) getTenantFromSourceField(extracted map[string]interface{}) string { |
||||
// Get the tenant ID from the source data
|
||||
value, ok := extracted[s.cfg.Source] |
||||
if !ok { |
||||
if Debug { |
||||
level.Debug(s.logger).Log("msg", "the tenant source does not exist in the extracted data", "source", s.cfg.Source) |
||||
} |
||||
return "" |
||||
} |
||||
|
||||
// Convert the value to string
|
||||
tenantID, err := getString(value) |
||||
if err != nil { |
||||
if Debug { |
||||
level.Debug(s.logger).Log("msg", "failed to convert value to string", "err", err, "type", reflect.TypeOf(value).String()) |
||||
} |
||||
return "" |
||||
} |
||||
|
||||
return tenantID |
||||
} |
@ -0,0 +1,156 @@ |
||||
package stages |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/cortexproject/cortex/pkg/util" |
||||
"github.com/grafana/loki/pkg/promtail/constants" |
||||
lokiutil "github.com/grafana/loki/pkg/util" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestTenantStage_Validation(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
tests := map[string]struct { |
||||
config *TenantConfig |
||||
expectedErr *string |
||||
}{ |
||||
"should pass on source config option set": { |
||||
config: &TenantConfig{ |
||||
Source: "tenant", |
||||
}, |
||||
expectedErr: nil, |
||||
}, |
||||
"should pass on value config option set": { |
||||
config: &TenantConfig{ |
||||
Value: "team-a", |
||||
}, |
||||
expectedErr: nil, |
||||
}, |
||||
"should fail on missing source and value": { |
||||
config: &TenantConfig{}, |
||||
expectedErr: lokiutil.StringRef(ErrTenantStageEmptySourceOrValue), |
||||
}, |
||||
"should fail on empty source": { |
||||
config: &TenantConfig{ |
||||
Source: "", |
||||
}, |
||||
expectedErr: lokiutil.StringRef(ErrTenantStageEmptySourceOrValue), |
||||
}, |
||||
"should fail on empty value": { |
||||
config: &TenantConfig{ |
||||
Value: "", |
||||
}, |
||||
expectedErr: lokiutil.StringRef(ErrTenantStageEmptySourceOrValue), |
||||
}, |
||||
"should fail on both source and value set": { |
||||
config: &TenantConfig{ |
||||
Source: "tenant", |
||||
Value: "team-a", |
||||
}, |
||||
expectedErr: lokiutil.StringRef(ErrTenantStageConflictingSourceAndValue), |
||||
}, |
||||
} |
||||
|
||||
for testName, testData := range tests { |
||||
testData := testData |
||||
|
||||
t.Run(testName, func(t *testing.T) { |
||||
stage, err := newTenantStage(util.Logger, testData.config) |
||||
|
||||
if testData.expectedErr != nil { |
||||
assert.EqualError(t, err, *testData.expectedErr) |
||||
assert.Nil(t, stage) |
||||
} else { |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, stage) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestTenantStage_Process(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
tests := map[string]struct { |
||||
config *TenantConfig |
||||
inputLabels model.LabelSet |
||||
inputExtracted map[string]interface{} |
||||
expectedTenant *string |
||||
}{ |
||||
"should not set the tenant if the source field is not defined in the extracted map": { |
||||
config: &TenantConfig{Source: "tenant_id"}, |
||||
inputLabels: model.LabelSet{}, |
||||
inputExtracted: map[string]interface{}{}, |
||||
expectedTenant: nil, |
||||
}, |
||||
"should not override the tenant if the source field is not defined in the extracted map": { |
||||
config: &TenantConfig{Source: "tenant_id"}, |
||||
inputLabels: model.LabelSet{constants.ReservedLabelTenantID: "foo"}, |
||||
inputExtracted: map[string]interface{}{}, |
||||
expectedTenant: lokiutil.StringRef("foo"), |
||||
}, |
||||
"should set the tenant if the source field is defined in the extracted map": { |
||||
config: &TenantConfig{Source: "tenant_id"}, |
||||
inputLabels: model.LabelSet{}, |
||||
inputExtracted: map[string]interface{}{"tenant_id": "bar"}, |
||||
expectedTenant: lokiutil.StringRef("bar"), |
||||
}, |
||||
"should override the tenant if the source field is defined in the extracted map": { |
||||
config: &TenantConfig{Source: "tenant_id"}, |
||||
inputLabels: model.LabelSet{constants.ReservedLabelTenantID: "foo"}, |
||||
inputExtracted: map[string]interface{}{"tenant_id": "bar"}, |
||||
expectedTenant: lokiutil.StringRef("bar"), |
||||
}, |
||||
"should not set the tenant if the source field data type can't be converted to string": { |
||||
config: &TenantConfig{Source: "tenant_id"}, |
||||
inputLabels: model.LabelSet{}, |
||||
inputExtracted: map[string]interface{}{"tenant_id": []string{"bar"}}, |
||||
expectedTenant: nil, |
||||
}, |
||||
"should set the tenant with the configured static value": { |
||||
config: &TenantConfig{Value: "bar"}, |
||||
inputLabels: model.LabelSet{}, |
||||
inputExtracted: map[string]interface{}{}, |
||||
expectedTenant: lokiutil.StringRef("bar"), |
||||
}, |
||||
"should override the tenant with the configured static value": { |
||||
config: &TenantConfig{Value: "bar"}, |
||||
inputLabels: model.LabelSet{constants.ReservedLabelTenantID: "foo"}, |
||||
inputExtracted: map[string]interface{}{}, |
||||
expectedTenant: lokiutil.StringRef("bar"), |
||||
}, |
||||
} |
||||
|
||||
for testName, testData := range tests { |
||||
testData := testData |
||||
|
||||
t.Run(testName, func(t *testing.T) { |
||||
stage, err := newTenantStage(util.Logger, testData.config) |
||||
require.NoError(t, err) |
||||
|
||||
// Process and dummy line and ensure nothing has changed except
|
||||
// the tenant reserved label
|
||||
timestamp := time.Unix(1, 1) |
||||
entry := "hello world" |
||||
labels := testData.inputLabels.Clone() |
||||
extracted := testData.inputExtracted |
||||
|
||||
stage.Process(labels, extracted, ×tamp, &entry) |
||||
|
||||
assert.Equal(t, time.Unix(1, 1), timestamp) |
||||
assert.Equal(t, "hello world", entry) |
||||
|
||||
actualTenant, ok := labels[constants.ReservedLabelTenantID] |
||||
if testData.expectedTenant == nil { |
||||
assert.False(t, ok) |
||||
} else { |
||||
assert.Equal(t, *testData.expectedTenant, string(actualTenant)) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,90 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"time" |
||||
|
||||
"github.com/gogo/protobuf/proto" |
||||
"github.com/golang/snappy" |
||||
"github.com/grafana/loki/pkg/logproto" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
// batch holds pending log streams waiting to be sent to Loki, and it's used
|
||||
// to reduce the number of push requests to Loki aggregating multiple log streams
|
||||
// and entries in a single batch request. In case of multi-tenant Promtail, log
|
||||
// streams for each tenant are stored in a dedicated batch.
|
||||
type batch struct { |
||||
streams map[model.Fingerprint]*logproto.Stream |
||||
bytes int |
||||
createdAt time.Time |
||||
} |
||||
|
||||
func newBatch(entries ...entry) *batch { |
||||
b := &batch{ |
||||
streams: map[model.Fingerprint]*logproto.Stream{}, |
||||
bytes: 0, |
||||
createdAt: time.Now(), |
||||
} |
||||
|
||||
// Add entries to the batch
|
||||
for _, entry := range entries { |
||||
b.add(entry) |
||||
} |
||||
|
||||
return b |
||||
} |
||||
|
||||
// add an entry to the batch
|
||||
func (b *batch) add(entry entry) { |
||||
b.bytes += len(entry.Line) |
||||
|
||||
// Append the entry to an already existing stream (if any)
|
||||
fp := entry.labels.FastFingerprint() |
||||
if stream, ok := b.streams[fp]; ok { |
||||
stream.Entries = append(stream.Entries, entry.Entry) |
||||
return |
||||
} |
||||
|
||||
// Add the entry as a new stream
|
||||
b.streams[fp] = &logproto.Stream{ |
||||
Labels: entry.labels.String(), |
||||
Entries: []logproto.Entry{entry.Entry}, |
||||
} |
||||
} |
||||
|
||||
// sizeBytes returns the current batch size in bytes
|
||||
func (b *batch) sizeBytes() int { |
||||
return b.bytes |
||||
} |
||||
|
||||
// sizeBytesAfter returns the size of the batch after the input entry
|
||||
// will be added to the batch itself
|
||||
func (b *batch) sizeBytesAfter(entry entry) int { |
||||
return b.bytes + len(entry.Line) |
||||
} |
||||
|
||||
// age of the batch since its creation
|
||||
func (b *batch) age() time.Duration { |
||||
return time.Since(b.createdAt) |
||||
} |
||||
|
||||
// encode the batch as snappy-compressed push request, and returns
|
||||
// the encoded bytes and the number of encoded entries
|
||||
func (b *batch) encode() ([]byte, int, error) { |
||||
req := logproto.PushRequest{ |
||||
Streams: make([]*logproto.Stream, 0, len(b.streams)), |
||||
} |
||||
|
||||
entriesCount := 0 |
||||
for _, stream := range b.streams { |
||||
req.Streams = append(req.Streams, stream) |
||||
entriesCount += len(stream.Entries) |
||||
} |
||||
|
||||
buf, err := proto.Marshal(&req) |
||||
if err != nil { |
||||
return nil, 0, err |
||||
} |
||||
buf = snappy.Encode(nil, buf) |
||||
return buf, entriesCount, nil |
||||
} |
@ -0,0 +1,105 @@ |
||||
package client |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestBatch_add(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
tests := map[string]struct { |
||||
inputEntries []entry |
||||
expectedSizeBytes int |
||||
}{ |
||||
"empty batch": { |
||||
inputEntries: []entry{}, |
||||
expectedSizeBytes: 0, |
||||
}, |
||||
"single stream with single log entry": { |
||||
inputEntries: []entry{ |
||||
{"tenant", model.LabelSet{}, logEntries[0].Entry}, |
||||
}, |
||||
expectedSizeBytes: len(logEntries[0].Entry.Line), |
||||
}, |
||||
"single stream with multiple log entries": { |
||||
inputEntries: []entry{ |
||||
{"tenant", model.LabelSet{}, logEntries[0].Entry}, |
||||
{"tenant", model.LabelSet{}, logEntries[1].Entry}, |
||||
}, |
||||
expectedSizeBytes: len(logEntries[0].Entry.Line) + len(logEntries[1].Entry.Line), |
||||
}, |
||||
"multiple streams with multiple log entries": { |
||||
inputEntries: []entry{ |
||||
{"tenant", model.LabelSet{"type": "a"}, logEntries[0].Entry}, |
||||
{"tenant", model.LabelSet{"type": "a"}, logEntries[1].Entry}, |
||||
{"tenant", model.LabelSet{"type": "b"}, logEntries[2].Entry}, |
||||
}, |
||||
expectedSizeBytes: len(logEntries[0].Entry.Line) + len(logEntries[1].Entry.Line) + len(logEntries[2].Entry.Line), |
||||
}, |
||||
} |
||||
|
||||
for testName, testData := range tests { |
||||
testData := testData |
||||
|
||||
t.Run(testName, func(t *testing.T) { |
||||
b := newBatch() |
||||
|
||||
for _, entry := range testData.inputEntries { |
||||
b.add(entry) |
||||
} |
||||
|
||||
assert.Equal(t, testData.expectedSizeBytes, b.sizeBytes()) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestBatch_encode(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
tests := map[string]struct { |
||||
inputBatch *batch |
||||
expectedEntriesCount int |
||||
}{ |
||||
"empty batch": { |
||||
inputBatch: newBatch(), |
||||
expectedEntriesCount: 0, |
||||
}, |
||||
"single stream with single log entry": { |
||||
inputBatch: newBatch( |
||||
entry{"tenant", model.LabelSet{}, logEntries[0].Entry}, |
||||
), |
||||
expectedEntriesCount: 1, |
||||
}, |
||||
"single stream with multiple log entries": { |
||||
inputBatch: newBatch( |
||||
entry{"tenant", model.LabelSet{}, logEntries[0].Entry}, |
||||
entry{"tenant", model.LabelSet{}, logEntries[1].Entry}, |
||||
), |
||||
expectedEntriesCount: 2, |
||||
}, |
||||
"multiple streams with multiple log entries": { |
||||
inputBatch: newBatch( |
||||
entry{"tenant", model.LabelSet{"type": "a"}, logEntries[0].Entry}, |
||||
entry{"tenant", model.LabelSet{"type": "a"}, logEntries[1].Entry}, |
||||
entry{"tenant", model.LabelSet{"type": "b"}, logEntries[2].Entry}, |
||||
), |
||||
expectedEntriesCount: 3, |
||||
}, |
||||
} |
||||
|
||||
for testName, testData := range tests { |
||||
testData := testData |
||||
|
||||
t.Run(testName, func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
_, entriesCount, err := testData.inputBatch.encode() |
||||
require.NoError(t, err) |
||||
assert.Equal(t, testData.expectedEntriesCount, entriesCount) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,7 @@ |
||||
package constants |
||||
|
||||
const ( |
||||
// Label reserved to override the tenant ID while processing
|
||||
// pipeline stages
|
||||
ReservedLabelTenantID = "__tenant_id__" |
||||
) |
Loading…
Reference in new issue