Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom Envoy JSON fields #3059

Merged
merged 1 commit into from
Oct 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions internal/envoy/v2/accesslog.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,14 @@ func FileAccessLogEnvoy(path string) []*accesslog.AccessLog {

// FileAccessLogJSON returns a new file based access log filter
// that will log in JSON format
func FileAccessLogJSON(path string, keys []string) []*accesslog.AccessLog {
func FileAccessLogJSON(path string, fields config.AccessLogFields) []*accesslog.AccessLog {

jsonformat := &_struct.Struct{
Fields: make(map[string]*_struct.Value),
}

for _, k := range keys {
// This will silently ignore invalid headers.
// TODO(youngnick): this should tell users if a header is not valid
// https://github.com/projectcontour/contour/issues/1507
if template, ok := config.JSONFields[k]; ok {
jsonformat.Fields[k] = sv(template)
}
for k, v := range fields.AsFieldMap() {
jsonformat.Fields[k] = sv(v)
}

return []*accesslog.AccessLog{{
Expand Down
21 changes: 13 additions & 8 deletions internal/envoy/v2/accesslog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ func TestFileAccessLog(t *testing.T) {
func TestJSONFileAccessLog(t *testing.T) {
tests := map[string]struct {
path string
headers []string
headers config.AccessLogFields
want []*envoy_accesslog.AccessLog
}{
"only timestamp": {
path: "/dev/stdout",
headers: []string{"@timestamp"},
headers: config.AccessLogFields([]string{"@timestamp"}),
want: []*envoy_accesslog.AccessLog{{
Name: wellknown.FileAccessLog,
ConfigType: &envoy_accesslog.AccessLog_TypedConfig{
Expand All @@ -75,13 +75,15 @@ func TestJSONFileAccessLog(t *testing.T) {
},
},
},
"invalid header should disappear": {
"custom fields should appear": {
path: "/dev/stdout",
headers: []string{
headers: config.AccessLogFields([]string{
"@timestamp",
"invalid",
"method",
},
"custom1=%REQ(X-CUSTOM-HEADER)%",
"custom2=%DURATION%.0",
"custom3=ST=%START_TIME(%s.%6f)%",
}),
want: []*envoy_accesslog.AccessLog{{
Name: wellknown.FileAccessLog,
ConfigType: &envoy_accesslog.AccessLog_TypedConfig{
Expand All @@ -90,8 +92,11 @@ func TestJSONFileAccessLog(t *testing.T) {
AccessLogFormat: &accesslog_v2.FileAccessLog_JsonFormat{
JsonFormat: &_struct.Struct{
Fields: map[string]*_struct.Value{
"@timestamp": sv(config.JSONFields["@timestamp"]),
"method": sv(config.JSONFields["method"]),
"@timestamp": sv("%START_TIME%"),
"method": sv("%REQ(:METHOD)%"),
"custom1": sv("%REQ(X-CUSTOM-HEADER)%"),
"custom2": sv("%DURATION%.0"),
"custom3": sv("ST=%START_TIME(%s.%6f)%"),
},
},
},
Expand Down
4 changes: 2 additions & 2 deletions internal/xdscache/v2/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ type ListenerConfig struct {
// AccessLogFields sets the fields that should be shown in JSON logs.
// Valid entries are the keys from internal/envoy/accesslog.go:jsonheaders
// Defaults to a particular set of fields.
AccessLogFields []string
AccessLogFields config.AccessLogFields

// RequestTimeout configures the request_timeout for all Connection Managers.
RequestTimeout timeout.Setting
Expand Down Expand Up @@ -181,7 +181,7 @@ func (lvc *ListenerConfig) accesslogType() string {

// accesslogFields returns the access log fields that should be configured
// for Envoy, or a default set if not configured.
func (lvc *ListenerConfig) accesslogFields() []string {
func (lvc *ListenerConfig) accesslogFields() config.AccessLogFields {
if lvc.AccessLogFields != nil {
return lvc.AccessLogFields
}
Expand Down
106 changes: 75 additions & 31 deletions pkg/config/accesslog.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,7 @@
package config

//JSONFields is the canonical translation table for JSON fields to Envoy log template formats,
//used for specifying fields for Envoy to log when JSON logging is enabled.
//Only fields specified in this map may be used for JSON logging.
var JSONFields = map[string]string{
"@timestamp": "%START_TIME%",
"ts": "%START_TIME%",
"authority": "%REQ(:AUTHORITY)%",
"bytes_received": "%BYTES_RECEIVED%",
"bytes_sent": "%BYTES_SENT%",
"downstream_local_address": "%DOWNSTREAM_LOCAL_ADDRESS%",
"downstream_remote_address": "%DOWNSTREAM_REMOTE_ADDRESS%",
"duration": "%DURATION%",
"method": "%REQ(:METHOD)%",
"path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%",
"protocol": "%PROTOCOL%",
"request_id": "%REQ(X-REQUEST-ID)%",
"requested_server_name": "%REQUESTED_SERVER_NAME%",
"response_code": "%RESPONSE_CODE%",
"response_flags": "%RESPONSE_FLAGS%",
"uber_trace_id": "%REQ(UBER-TRACE-ID)%",
"upstream_cluster": "%UPSTREAM_CLUSTER%",
"upstream_host": "%UPSTREAM_HOST%",
"upstream_local_address": "%UPSTREAM_LOCAL_ADDRESS%",
"upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%",
"user_agent": "%REQ(USER-AGENT)%",
"x_forwarded_for": "%REQ(X-FORWARDED-FOR)%",
"x_trace_id": "%REQ(X-TRACE-ID)%",
}

// DefaultFields are fields that will be included by default when JSON logging is enabled.
var DefaultFields = []string{
var DefaultFields = AccessLogFields([]string{
"@timestamp",
"authority",
"bytes_received",
Expand All @@ -52,7 +23,80 @@ var DefaultFields = []string{
"upstream_service_time",
"user_agent",
"x_forwarded_for",
}
})

// DEFAULT_ACCESS_LOG_TYPE is the default access log format.
const DEFAULT_ACCESS_LOG_TYPE = EnvoyAccessLog

// jsonFields is the canonical translation table for JSON fields to Envoy log template formats,
// used for specifying fields for Envoy to log when JSON logging is enabled.
var jsonFields = map[string]string{
"@timestamp": "%START_TIME%",
"ts": "%START_TIME%",
"authority": "%REQ(:AUTHORITY)%",
"method": "%REQ(:METHOD)%",
"path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%",
"request_id": "%REQ(X-REQUEST-ID)%",
"uber_trace_id": "%REQ(UBER-TRACE-ID)%",
"upstream_service_time": "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%",
"user_agent": "%REQ(USER-AGENT)%",
"x_forwarded_for": "%REQ(X-FORWARDED-FOR)%",
"x_trace_id": "%REQ(X-TRACE-ID)%",
}

// envoySimpleOperators is the list of known supported Envoy log template keywords that do not
// have arguments nor require canonical translations.
var envoySimpleOperators = map[string]struct{}{
"BYTES_RECEIVED": {},
"BYTES_SENT": {},
"CONNECTION_ID": {},
"CONNECTION_TERMINATION_DETAILS": {},
"DOWNSTREAM_DIRECT_REMOTE_ADDRESS": {},
"DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT": {},
"DOWNSTREAM_LOCAL_ADDRESS": {},
"DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT": {},
"DOWNSTREAM_LOCAL_PORT": {},
"DOWNSTREAM_LOCAL_SUBJECT": {},
"DOWNSTREAM_LOCAL_URI_SAN": {},
"DOWNSTREAM_PEER_CERT": {},
"DOWNSTREAM_PEER_CERT_V_END": {},
"DOWNSTREAM_PEER_CERT_V_START": {},
"DOWNSTREAM_PEER_FINGERPRINT_1": {},
"DOWNSTREAM_PEER_FINGERPRINT_256": {},
"DOWNSTREAM_PEER_ISSUER": {},
"DOWNSTREAM_PEER_SERIAL": {},
"DOWNSTREAM_PEER_SUBJECT": {},
"DOWNSTREAM_PEER_URI_SAN": {},
"DOWNSTREAM_REMOTE_ADDRESS": {},
"DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT": {},
"DOWNSTREAM_TLS_CIPHER": {},
"DOWNSTREAM_TLS_SESSION_ID": {},
"DOWNSTREAM_TLS_VERSION": {},
"DURATION": {},
"GRPC_STATUS": {},
"HOSTNAME": {},
"LOCAL_REPLY_BODY": {},
"PROTOCOL": {},
"REQUESTED_SERVER_NAME": {},
"REQUEST_DURATION": {},
"RESPONSE_CODE": {},
"RESPONSE_CODE_DETAILS": {},
"RESPONSE_DURATION": {},
"RESPONSE_FLAGS": {},
"RESPONSE_TX_DURATION": {},
"ROUTE_NAME": {},
"START_TIME": {},
"UPSTREAM_CLUSTER": {},
"UPSTREAM_HOST": {},
"UPSTREAM_LOCAL_ADDRESS": {},
"UPSTREAM_TRANSPORT_FAILURE_REASON": {},
}

// envoyComplexOperators is the list of known Envoy log template keywords that require
// arguments.
var envoyComplexOperators = map[string]struct{}{
"REQ": {},
"RESP": {},
"START_TIME": {},
"TRAILER": {},
}
93 changes: 88 additions & 5 deletions pkg/config/parameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -89,6 +90,90 @@ func (a AccessLogType) Validate() error {
const EnvoyAccessLog AccessLogType = "envoy"
const JSONAccessLog AccessLogType = "json"

type AccessLogFields []string
jpeach marked this conversation as resolved.
Show resolved Hide resolved

func (a AccessLogFields) Validate() error {
// Capture Groups:
// Given string "the start time is %START_TIME(%s):3% wow!"
//
// 0. Whole match "%START_TIME(%s):3%"
// 1. Full operator: "START_TIME(%s):3%"
// 2. Operator Name: "START_TIME"
// 3. Arguments: "(%s)"
// 4. Truncation length: ":3"
re := regexp.MustCompile(`%(([A-Z_]+)(\([^)]+\)(:[0-9]+)?)?%)?`)

for key, val := range a.AsFieldMap() {
if val == "" {
return fmt.Errorf("invalid JSON log field name %s", key)
}

if jsonFields[key] == val {
continue
}

// FindAllStringSubmatch will always return a slice with matches where every slice is a slice
// of submatches with length of 5 (number of capture groups + 1).
tokens := re.FindAllStringSubmatch(val, -1)
if len(tokens) == 0 {
continue
}

for _, f := range tokens {
op := f[2]
if op == "" {
return fmt.Errorf("invalid JSON field: %s, invalid Envoy format: %s", val, f)
}

_, okSimple := envoySimpleOperators[op]
_, okComplex := envoyComplexOperators[op]
if !okSimple && !okComplex {
return fmt.Errorf("invalid JSON field: %s, invalid Envoy format: %s, invalid Envoy operator: %s", val, f, op)
}

if (op == "REQ" || op == "RESP" || op == "TRAILER") && f[3] == "" {
return fmt.Errorf("invalid JSON field: %s, invalid Envoy format: %s, arguments required for operator: %s", val, f, op)
}

// START_TIME cannot not have truncation length.
if op == "START_TIME" && f[4] != "" {
return fmt.Errorf("invalid JSON field: %s, invalid Envoy format: %s, operator %s cannot have truncation length", val, f, op)
}
}
}

return nil
}

func (a AccessLogFields) AsFieldMap() map[string]string {
fieldMap := map[string]string{}

for _, val := range a {
parts := strings.SplitN(val, "=", 2)

if len(parts) == 1 {
operator, foundInFieldMapping := jsonFields[val]
_, isSimpleOperator := envoySimpleOperators[strings.ToUpper(val)]

if isSimpleOperator && !foundInFieldMapping {
// Operator name is known to be simple, upcase and wrap it in percents.
fieldMap[val] = fmt.Sprintf("%%%s%%", strings.ToUpper(val))
} else if foundInFieldMapping {
// Operator name has a known mapping, store the result of the mapping.
fieldMap[val] = operator
} else {
// Operator name not found, save as emptystring and let validation catch it later.
fieldMap[val] = ""
}
} else {
// Value is a full key:value pair, store it as is.
fieldMap[parts[0]] = parts[1]
}
}

return fieldMap
}

// HTTPVersionType is the name of a supported HTTP version.
type HTTPVersionType string

Expand Down Expand Up @@ -288,7 +373,7 @@ type Parameters struct {

// AccessLogFields sets the fields that JSON logging will
// output when AccessLogFormat is json.
AccessLogFields []string `yaml:"json-fields,omitempty"`
AccessLogFields AccessLogFields `yaml:"json-fields,omitempty"`

// TLS contains TLS policy parameters.
TLS TLSParameters `yaml:"tls,omitempty"`
Expand Down Expand Up @@ -341,10 +426,8 @@ func (p *Parameters) Validate() error {
return err
}

for _, f := range p.AccessLogFields {
if _, ok := JSONFields[f]; !ok {
return fmt.Errorf("invalid JSON log field name %s", f)
}
if err := p.AccessLogFields.Validate(); err != nil {
return err
}

// Check TLS secret names.
Expand Down
42 changes: 42 additions & 0 deletions pkg/config/parameters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,48 @@ func TestValidateAccessLogType(t *testing.T) {
assert.NoError(t, JSONAccessLog.Validate())
}

func TestValidateAccessLogFields(t *testing.T) {
errorCases := [][]string{
{"dog", "cat"},
{"req"},
{"resp"},
{"trailer"},
{"@timestamp", "dog"},
{"@timestamp", "content-id=%REQ=dog%"},
{"@timestamp", "content-id=%dog(%"},
{"@timestamp", "content-id=%REQ()%"},
{"@timestamp", "content-id=%DOG%"},
{"@timestamp", "duration=my durations % are %DURATION%.0 and %REQ(:METHOD)%"},
{"invalid=%REQ%"},
{"invalid=%TRAILER%"},
{"invalid=%RESP%"},
{"@timestamp", "invalid=%START_TIME(%s.%6f):10%"},
}

for _, c := range errorCases {
assert.Error(t, AccessLogFields(c).Validate(), c)
}

successCases := [][]string{
{"@timestamp", "method"},
{"start_time"},
{"@timestamp", "response_duration"},
{"@timestamp", "duration=%DURATION%.0"},
{"@timestamp", "duration=My duration=%DURATION%.0"},
{"@timestamp", "duratin=%START_TIME(%s.%6f)%"},
{"@timestamp", "content-id=%REQ(X-CONTENT-ID)%"},
{"@timestamp", "content-id=%REQ(X-CONTENT-ID):10%"},
{"@timestamp", "length=%RESP(CONTENT-LENGTH):10%"},
{"@timestamp", "trailer=%TRAILER(CONTENT-LENGTH):10%"},
{"@timestamp", "duration=my durations are %DURATION%.0 and method is %REQ(:METHOD)%"},
{"dog=pug", "cat=black"},
}

for _, c := range successCases {
assert.NoError(t, AccessLogFields(c).Validate(), c)
}
}

func TestValidateHTTPVersionType(t *testing.T) {
assert.Error(t, HTTPVersionType("").Validate())
assert.Error(t, HTTPVersionType("foo").Validate())
Expand Down