Repository: kumahq/kuma
Pull request #1399 (2021-01-08 13:22:01)

feat(kuma-cp) transparent proxy v3
### Summary

This PR uses restores support for transparent proxy in V3.

To do this we had to upgrade to Envoy 1.17 (done) and use new Envoy config that has bind_to_port undeprecated.
For those new proto I had to upgrade the go-control-plane to the newest version from master. They introduced changes in Snapshot so I had to update our modified Snapshot as an interface.

### Documentation

- [X] No docs


nickolaev: This one is not to be backported, AFAIK.

Patch:
diff --git a/api/mesh/v1alpha1/dataplane_helpers.go b/api/mesh/v1alpha1/dataplane_helpers.go
index 6dc979567..40e74ef10 100644
--- a/api/mesh/v1alpha1/dataplane_helpers.go
+++ b/api/mesh/v1alpha1/dataplane_helpers.go
@@ -351,6 +351,9 @@ func (d *Dataplane) GetIdentifyingService() string {
 }
 
 func (d *Dataplane) IsIngress() bool {
+	if d.GetNetworking() == nil {
+		return false
+	}
 	return d.Networking.Ingress != nil
 }
 
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/server.go b/app/kuma-dp/pkg/dataplane/accesslogs/server.go
index a248473b1..4ce9c613c 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/server.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/server.go
@@ -2,14 +2,12 @@ package accesslogs
 
 import (
 	"fmt"
-	"io"
 	"net"
-	"sync/atomic"
 
+	v2 "github.com/kumahq/kuma/app/kuma-dp/pkg/dataplane/accesslogs/v2"
+	v3 "github.com/kumahq/kuma/app/kuma-dp/pkg/dataplane/accesslogs/v3"
 	"github.com/kumahq/kuma/pkg/xds/envoy"
 
-	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v2"
-	"github.com/pkg/errors"
 	"google.golang.org/grpc"
 
 	kumadp "github.com/kumahq/kuma/pkg/config/app/kuma-dp"
@@ -22,12 +20,8 @@ var logger = core.Log.WithName("accesslogs-server")
 var _ component.Component = &accessLogServer{}
 
 type accessLogServer struct {
-	server     *grpc.Server
-	address    string
-	newHandler logHandlerFactoryFunc
-
-	// streamCount for counting streams
-	streamCount int64
+	server  *grpc.Server
+	address string
 }
 
 func (s *accessLogServer) NeedLeaderElection() bool {
@@ -37,58 +31,14 @@ func (s *accessLogServer) NeedLeaderElection() bool {
 func NewAccessLogServer(dataplane kumadp.Dataplane) *accessLogServer {
 	address := envoy.AccessLogSocketName(dataplane.Name, dataplane.Mesh)
 	return &accessLogServer{
-		server:     grpc.NewServer(),
-		newHandler: defaultHandler,
-		address:    address,
-	}
-}
-
-func (s *accessLogServer) StreamAccessLogs(stream envoy_accesslog.AccessLogService_StreamAccessLogsServer) (err error) {
-	// increment stream count
-	streamID := atomic.AddInt64(&s.streamCount, 1)
-
-	totalRequests := 0
-
-	log := logger.WithValues("streamID", streamID)
-	log.Info("starting a new Access Logs stream")
-	defer func() {
-		log.Info("Access Logs stream is terminated", "totalRequests", totalRequests)
-		if err != nil {
-			log.Error(err, "Access Logs stream terminated with an error")
-		}
-	}()
-
-	initialized := false
-	var handler logHandler
-	for {
-		msg, err := stream.Recv()
-		if err != nil {
-			if err == io.EOF {
-				return nil
-			}
-			return err
-		}
-
-		totalRequests++
-
-		if !initialized {
-			initialized = true
-
-			handler, err = s.newHandler(log, msg)
-			if err != nil {
-				return errors.Wrap(err, "failed to initialize Access Logs stream")
-			}
-			defer handler.Close()
-		}
-
-		if err := handler.Handle(msg); err != nil {
-			return err
-		}
+		server:  grpc.NewServer(),
+		address: address,
 	}
 }
 
 func (s *accessLogServer) Start(stop <-chan struct{}) error {
-	envoy_accesslog.RegisterAccessLogServiceServer(s.server, s)
+	v2.RegisterAccessLogServer(s.server)
+	v3.RegisterAccessLogServer(s.server)
 
 	lis, err := net.Listen("unix", s.address)
 	if err != nil {
@@ -110,5 +60,3 @@ func (s *accessLogServer) Start(stop <-chan struct{}) error {
 		return nil
 	}
 }
-
-var _ envoy_accesslog.AccessLogServiceServer = &accessLogServer{}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/factories.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/factories.go
similarity index 93%
rename from app/kuma-dp/pkg/dataplane/accesslogs/factories.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/factories.go
index 0ef7d059c..38051bee0 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/factories.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/factories.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 import (
 	"strings"
@@ -9,7 +9,7 @@ import (
 
 	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v2"
 
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 )
 
 func defaultHandler(log logr.Logger, msg *envoy_accesslog.StreamAccessLogsMessage) (logHandler, error) {
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/handler.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/handler.go
similarity index 94%
rename from app/kuma-dp/pkg/dataplane/accesslogs/handler.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/handler.go
index bccf481b0..363d3e5f9 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/handler.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/handler.go
@@ -1,11 +1,11 @@
-package accesslogs
+package v2
 
 import (
 	"github.com/pkg/errors"
 
 	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v2"
 
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 )
 
 type handler struct {
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/interfaces.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/interfaces.go
similarity index 97%
rename from app/kuma-dp/pkg/dataplane/accesslogs/interfaces.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/interfaces.go
index a0d7a6e58..3f7818d64 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/interfaces.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/interfaces.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 import (
 	"io"
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/sender.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/sender.go
similarity index 97%
rename from app/kuma-dp/pkg/dataplane/accesslogs/sender.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/sender.go
index 90621d4de..c7290a4a7 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/sender.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/sender.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 import (
 	"net"
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v2/server.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/server.go
new file mode 100644
index 000000000..aeb2e1383
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/server.go
@@ -0,0 +1,74 @@
+package v2
+
+import (
+	"io"
+	"sync/atomic"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v2"
+	"github.com/pkg/errors"
+	"google.golang.org/grpc"
+
+	"github.com/kumahq/kuma/pkg/core"
+)
+
+var logger = core.Log.WithName("accesslogs-server")
+
+type accessLogServer struct {
+	newHandler logHandlerFactoryFunc
+
+	// streamCount for counting streams
+	streamCount int64
+}
+
+var _ envoy_accesslog.AccessLogServiceServer = &accessLogServer{}
+
+func RegisterAccessLogServer(server *grpc.Server) {
+	srv := &accessLogServer{
+		newHandler: defaultHandler,
+	}
+	envoy_accesslog.RegisterAccessLogServiceServer(server, srv)
+}
+
+func (s *accessLogServer) StreamAccessLogs(stream envoy_accesslog.AccessLogService_StreamAccessLogsServer) (err error) {
+	// increment stream count
+	streamID := atomic.AddInt64(&s.streamCount, 1)
+
+	totalRequests := 0
+
+	log := logger.WithValues("streamID", streamID)
+	log.Info("starting a new Access Logs stream")
+	defer func() {
+		log.Info("Access Logs stream is terminated", "totalRequests", totalRequests)
+		if err != nil {
+			log.Error(err, "Access Logs stream terminated with an error")
+		}
+	}()
+
+	initialized := false
+	var handler logHandler
+	for {
+		msg, err := stream.Recv()
+		if err != nil {
+			if err == io.EOF {
+				return nil
+			}
+			return err
+		}
+
+		totalRequests++
+
+		if !initialized {
+			initialized = true
+
+			handler, err = s.newHandler(log, msg)
+			if err != nil {
+				return errors.Wrap(err, "failed to initialize Access Logs stream")
+			}
+			defer handler.Close()
+		}
+
+		if err := handler.Handle(msg); err != nil {
+			return err
+		}
+	}
+}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories.go
new file mode 100644
index 000000000..6927f170c
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories.go
@@ -0,0 +1,46 @@
+package v3
+
+import (
+	"strings"
+
+	"github.com/pkg/errors"
+
+	"github.com/go-logr/logr"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+)
+
+func defaultHandler(log logr.Logger, msg *envoy_accesslog.StreamAccessLogsMessage) (logHandler, error) {
+	parts := strings.SplitN(msg.GetIdentifier().GetLogName(), ";", 2)
+	if len(parts) != 2 {
+		return nil, errors.Errorf("log name %q has invalid format: expected %d components separated by ';', got %d", msg.GetIdentifier().GetLogName(), 2, len(parts))
+	}
+	address, formatString := parts[0], parts[1]
+
+	format, err := accesslog.ParseFormat(formatString)
+	if err != nil {
+		return nil, err
+	}
+
+	sender, err := defaultSender(log, address)
+	if err != nil {
+		return nil, err
+	}
+	if err := sender.Connect(); err != nil {
+		return nil, err
+	}
+
+	return &handler{
+		format: format,
+		sender: sender,
+	}, nil
+}
+
+func defaultSender(log logr.Logger, address string) (logSender, error) {
+	return &sender{
+		log:     log,
+		address: address,
+	}, nil
+}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler.go
new file mode 100644
index 000000000..f7e9530f5
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler.go
@@ -0,0 +1,46 @@
+package v3
+
+import (
+	"github.com/pkg/errors"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+)
+
+type handler struct {
+	format *accesslog.AccessLogFormat
+	sender logSender
+}
+
+func (h *handler) Handle(msg *envoy_accesslog.StreamAccessLogsMessage) error {
+	switch logEntries := msg.GetLogEntries().(type) {
+	case *envoy_accesslog.StreamAccessLogsMessage_HttpLogs:
+		for _, httpLogEntry := range logEntries.HttpLogs.GetLogEntry() {
+			record, err := h.format.FormatHttpLogEntry(httpLogEntry)
+			if err != nil {
+				return errors.Wrapf(err, "failed to format an HTTP log entry %v as %q", httpLogEntry, h.format)
+			}
+			if err := h.sender.Send(record); err != nil {
+				return err
+			}
+		}
+	case *envoy_accesslog.StreamAccessLogsMessage_TcpLogs:
+		for _, tcpLogEntry := range logEntries.TcpLogs.GetLogEntry() {
+			record, err := h.format.FormatTcpLogEntry(tcpLogEntry)
+			if err != nil {
+				return errors.Wrapf(err, "failed to format a TCP log entry %v as %q", tcpLogEntry, h.format)
+			}
+			if err := h.sender.Send(record); err != nil {
+				return err
+			}
+		}
+	default:
+		return errors.Errorf("unknown type of log entries: %T", msg.GetLogEntries())
+	}
+	return nil
+}
+
+func (h *handler) Close() error {
+	return h.sender.Close()
+}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/interfaces.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/interfaces.go
new file mode 100644
index 000000000..09e0f935a
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/interfaces.go
@@ -0,0 +1,25 @@
+package v3
+
+import (
+	"io"
+
+	"github.com/go-logr/logr"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+)
+
+// logHandler represents a contract between a log stream receiver and a log handler.
+type logHandler interface {
+	Handle(msg *envoy_accesslog.StreamAccessLogsMessage) error
+	io.Closer
+}
+
+// logSender represents a contract between a log handler and a log sender.
+type logSender interface {
+	Connect() error
+	Send(entry string) error
+	io.Closer
+}
+
+// logHandlerFactoryFunc represents a factory of log handler implementations.
+type logHandlerFactoryFunc = func(log logr.Logger, msg *envoy_accesslog.StreamAccessLogsMessage) (logHandler, error)
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/sender.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/sender.go
new file mode 100644
index 000000000..b64ecf8e9
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/sender.go
@@ -0,0 +1,42 @@
+package v3
+
+import (
+	"net"
+	"time"
+
+	"github.com/pkg/errors"
+
+	"github.com/go-logr/logr"
+)
+
+const (
+	defaultConnectTimeout = 5 * time.Second
+)
+
+type sender struct {
+	log     logr.Logger
+	address string
+	conn    net.Conn
+}
+
+func (s *sender) Connect() error {
+	conn, err := net.DialTimeout("tcp", s.address, defaultConnectTimeout)
+	if err != nil {
+		return errors.Wrapf(err, "failed to connect to a TCP logging backend: %s", s.address)
+	}
+	s.log.Info("connected to TCP logging backend", "address", s.address)
+	s.conn = conn
+	return nil
+}
+
+func (s *sender) Send(record string) error {
+	_, err := s.conn.Write([]byte(record))
+	return errors.Wrapf(err, "failed to send a log entry to a TCP logging backend: %s", s.address)
+}
+
+func (s *sender) Close() error {
+	if s.conn != nil {
+		return s.conn.Close()
+	}
+	return nil
+}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/server.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/server.go
new file mode 100644
index 000000000..c94fa13b5
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/server.go
@@ -0,0 +1,74 @@
+package v3
+
+import (
+	"io"
+	"sync/atomic"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+	"github.com/pkg/errors"
+	"google.golang.org/grpc"
+
+	"github.com/kumahq/kuma/pkg/core"
+)
+
+var logger = core.Log.WithName("accesslogs-server")
+
+type accessLogServer struct {
+	newHandler logHandlerFactoryFunc
+
+	// streamCount for counting streams
+	streamCount int64
+}
+
+var _ envoy_accesslog.AccessLogServiceServer = &accessLogServer{}
+
+func RegisterAccessLogServer(server *grpc.Server) {
+	srv := &accessLogServer{
+		newHandler: defaultHandler,
+	}
+	envoy_accesslog.RegisterAccessLogServiceServer(server, srv)
+}
+
+func (s *accessLogServer) StreamAccessLogs(stream envoy_accesslog.AccessLogService_StreamAccessLogsServer) (err error) {
+	// increment stream count
+	streamID := atomic.AddInt64(&s.streamCount, 1)
+
+	totalRequests := 0
+
+	log := logger.WithValues("streamID", streamID)
+	log.Info("starting a new Access Logs stream")
+	defer func() {
+		log.Info("Access Logs stream is terminated", "totalRequests", totalRequests)
+		if err != nil {
+			log.Error(err, "Access Logs stream terminated with an error")
+		}
+	}()
+
+	initialized := false
+	var handler logHandler
+	for {
+		msg, err := stream.Recv()
+		if err != nil {
+			if err == io.EOF {
+				return nil
+			}
+			return err
+		}
+
+		totalRequests++
+
+		if !initialized {
+			initialized = true
+
+			handler, err = s.newHandler(log, msg)
+			if err != nil {
+				return errors.Wrap(err, "failed to initialize Access Logs stream")
+			}
+			defer handler.Close()
+		}
+
+		if err := handler.Handle(msg); err != nil {
+			return err
+		}
+	}
+}
diff --git a/go.mod b/go.mod
index fbb03c51a..606be5fb0 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/emicklei/go-restful v2.14.2+incompatible
-	github.com/envoyproxy/go-control-plane v0.9.8
+	github.com/envoyproxy/go-control-plane v0.9.9-0.20210107210257-6e771204fae6
 	github.com/envoyproxy/protoc-gen-validate v0.4.1
 	github.com/ghodss/yaml v1.0.0
 	github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0
diff --git a/go.sum b/go.sum
index 3af10e965..af85346d1 100644
--- a/go.sum
+++ b/go.sum
@@ -345,8 +345,9 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/go-control-plane v0.9.7-0.20200730005029-803dd64f0468/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
 github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-github.com/envoyproxy/go-control-plane v0.9.8 h1:bbmjRkjmP0ZggMoahdNMmJFFnK7v5H+/j5niP5QH6bg=
 github.com/envoyproxy/go-control-plane v0.9.8/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210107210257-6e771204fae6 h1:DZNZDoFHT7Q2zxdwbHkt2HFexENxiXTNd44M9pertiw=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210107210257-6e771204fae6/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.4.1 h1:7dLaJvASGRD7X49jSCSXXHwKPm0ZN9r9kJD+p+vS7dM=
 github.com/envoyproxy/protoc-gen-validate v0.4.1/go.mod h1:E+IEazqdaWv3FrnGtZIu3b9fPFMK8AzeTTrk9SfVwWs=
diff --git a/pkg/core/resources/apis/mesh/mesh_validator.go b/pkg/core/resources/apis/mesh/mesh_validator.go
index 3c7f83250..fe1fd8e5e 100644
--- a/pkg/core/resources/apis/mesh/mesh_validator.go
+++ b/pkg/core/resources/apis/mesh/mesh_validator.go
@@ -9,7 +9,7 @@ import (
 
 	mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1"
 	"github.com/kumahq/kuma/pkg/core/validators"
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 	"github.com/kumahq/kuma/pkg/util/proto"
 )
 
diff --git a/pkg/envoy/accesslog/commands.go b/pkg/envoy/accesslog/v2/commands.go
similarity index 99%
rename from pkg/envoy/accesslog/commands.go
rename to pkg/envoy/accesslog/v2/commands.go
index a71f59d7f..fb69f1288 100644
--- a/pkg/envoy/accesslog/commands.go
+++ b/pkg/envoy/accesslog/v2/commands.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/doc.go b/pkg/envoy/accesslog/v2/doc.go
similarity index 97%
rename from pkg/envoy/accesslog/doc.go
rename to pkg/envoy/accesslog/v2/doc.go
index cad9ab8cb..67e07fb7a 100644
--- a/pkg/envoy/accesslog/doc.go
+++ b/pkg/envoy/accesslog/v2/doc.go
@@ -20,4 +20,4 @@ The initial implementation is missing the following features:
 2. `%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%` commands return a stub value
 3. `%FILTER_STATE(KEY):Z%` commands return a stub value
 */
-package accesslog
+package v2
diff --git a/pkg/envoy/accesslog/dynamic_metadata_operator.go b/pkg/envoy/accesslog/v2/dynamic_metadata_operator.go
similarity index 99%
rename from pkg/envoy/accesslog/dynamic_metadata_operator.go
rename to pkg/envoy/accesslog/v2/dynamic_metadata_operator.go
index 225c155cb..0c30acf9e 100644
--- a/pkg/envoy/accesslog/dynamic_metadata_operator.go
+++ b/pkg/envoy/accesslog/v2/dynamic_metadata_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"strconv"
diff --git a/pkg/envoy/accesslog/field_operator.go b/pkg/envoy/accesslog/v2/field_operator.go
similarity index 99%
rename from pkg/envoy/accesslog/field_operator.go
rename to pkg/envoy/accesslog/v2/field_operator.go
index 77f511feb..741ce876d 100644
--- a/pkg/envoy/accesslog/field_operator.go
+++ b/pkg/envoy/accesslog/v2/field_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/filter_state_operator.go b/pkg/envoy/accesslog/v2/filter_state_operator.go
similarity index 99%
rename from pkg/envoy/accesslog/filter_state_operator.go
rename to pkg/envoy/accesslog/v2/filter_state_operator.go
index 274a01de5..0638508a1 100644
--- a/pkg/envoy/accesslog/filter_state_operator.go
+++ b/pkg/envoy/accesslog/v2/filter_state_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"strconv"
diff --git a/pkg/envoy/accesslog/format.go b/pkg/envoy/accesslog/v2/format.go
similarity index 99%
rename from pkg/envoy/accesslog/format.go
rename to pkg/envoy/accesslog/v2/format.go
index 59e085960..3cee323ea 100644
--- a/pkg/envoy/accesslog/format.go
+++ b/pkg/envoy/accesslog/v2/format.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"strings"
diff --git a/pkg/envoy/accesslog/format_parser.go b/pkg/envoy/accesslog/v2/format_parser.go
similarity index 99%
rename from pkg/envoy/accesslog/format_parser.go
rename to pkg/envoy/accesslog/v2/format_parser.go
index c05a956c0..9679d2236 100644
--- a/pkg/envoy/accesslog/format_parser.go
+++ b/pkg/envoy/accesslog/v2/format_parser.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"regexp"
diff --git a/pkg/envoy/accesslog/header_formatter.go b/pkg/envoy/accesslog/v2/header_formatter.go
similarity index 99%
rename from pkg/envoy/accesslog/header_formatter.go
rename to pkg/envoy/accesslog/v2/header_formatter.go
index bfaeb6ae3..f82df0633 100644
--- a/pkg/envoy/accesslog/header_formatter.go
+++ b/pkg/envoy/accesslog/v2/header_formatter.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"strconv"
diff --git a/pkg/envoy/accesslog/interfaces.go b/pkg/envoy/accesslog/v2/interfaces.go
similarity index 99%
rename from pkg/envoy/accesslog/interfaces.go
rename to pkg/envoy/accesslog/v2/interfaces.go
index 60feac872..30de36719 100644
--- a/pkg/envoy/accesslog/interfaces.go
+++ b/pkg/envoy/accesslog/v2/interfaces.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v2"
diff --git a/pkg/envoy/accesslog/placeholder_fragment.go b/pkg/envoy/accesslog/v2/placeholder_fragment.go
similarity index 98%
rename from pkg/envoy/accesslog/placeholder_fragment.go
rename to pkg/envoy/accesslog/v2/placeholder_fragment.go
index 19701a590..b17f13353 100644
--- a/pkg/envoy/accesslog/placeholder_fragment.go
+++ b/pkg/envoy/accesslog/v2/placeholder_fragment.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v2"
diff --git a/pkg/envoy/accesslog/request_header_operator.go b/pkg/envoy/accesslog/v2/request_header_operator.go
similarity index 99%
rename from pkg/envoy/accesslog/request_header_operator.go
rename to pkg/envoy/accesslog/v2/request_header_operator.go
index 066168228..d7cae8605 100644
--- a/pkg/envoy/accesslog/request_header_operator.go
+++ b/pkg/envoy/accesslog/v2/request_header_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/response_flags.go b/pkg/envoy/accesslog/v2/response_flags.go
similarity index 98%
rename from pkg/envoy/accesslog/response_flags.go
rename to pkg/envoy/accesslog/v2/response_flags.go
index a8a3899b3..81e7a4397 100644
--- a/pkg/envoy/accesslog/response_flags.go
+++ b/pkg/envoy/accesslog/v2/response_flags.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 // List of supported response flags.
 const (
diff --git a/pkg/envoy/accesslog/response_header_operator.go b/pkg/envoy/accesslog/v2/response_header_operator.go
similarity index 98%
rename from pkg/envoy/accesslog/response_header_operator.go
rename to pkg/envoy/accesslog/v2/response_header_operator.go
index 5bcab2157..2fcbb9731 100644
--- a/pkg/envoy/accesslog/response_header_operator.go
+++ b/pkg/envoy/accesslog/v2/response_header_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/response_trailer_operator.go b/pkg/envoy/accesslog/v2/response_trailer_operator.go
similarity index 98%
rename from pkg/envoy/accesslog/response_trailer_operator.go
rename to pkg/envoy/accesslog/v2/response_trailer_operator.go
index b966d2243..104b5dda1 100644
--- a/pkg/envoy/accesslog/response_trailer_operator.go
+++ b/pkg/envoy/accesslog/v2/response_trailer_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/start_time_operator.go b/pkg/envoy/accesslog/v2/start_time_operator.go
similarity index 98%
rename from pkg/envoy/accesslog/start_time_operator.go
rename to pkg/envoy/accesslog/v2/start_time_operator.go
index 648f9f0b7..dee7afe33 100644
--- a/pkg/envoy/accesslog/start_time_operator.go
+++ b/pkg/envoy/accesslog/v2/start_time_operator.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/text_span_fragment.go b/pkg/envoy/accesslog/v2/text_span_fragment.go
similarity index 98%
rename from pkg/envoy/accesslog/text_span_fragment.go
rename to pkg/envoy/accesslog/v2/text_span_fragment.go
index fe3d34a97..af299d44c 100644
--- a/pkg/envoy/accesslog/text_span_fragment.go
+++ b/pkg/envoy/accesslog/v2/text_span_fragment.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v2"
diff --git a/pkg/envoy/accesslog/tls_cipher_suites.go b/pkg/envoy/accesslog/v2/tls_cipher_suites.go
similarity index 99%
rename from pkg/envoy/accesslog/tls_cipher_suites.go
rename to pkg/envoy/accesslog/v2/tls_cipher_suites.go
index 89e17f8a6..f29ee3c27 100644
--- a/pkg/envoy/accesslog/tls_cipher_suites.go
+++ b/pkg/envoy/accesslog/v2/tls_cipher_suites.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 import (
 	"fmt"
diff --git a/pkg/envoy/accesslog/util.go b/pkg/envoy/accesslog/v2/util.go
similarity index 96%
rename from pkg/envoy/accesslog/util.go
rename to pkg/envoy/accesslog/v2/util.go
index 896dfa547..5d2917375 100644
--- a/pkg/envoy/accesslog/util.go
+++ b/pkg/envoy/accesslog/v2/util.go
@@ -1,4 +1,4 @@
-package accesslog
+package v2
 
 // stringList represents a list of strings.
 type stringList []string
diff --git a/pkg/envoy/accesslog/v3/commands.go b/pkg/envoy/accesslog/v3/commands.go
new file mode 100644
index 000000000..5b470f75b
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/commands.go
@@ -0,0 +1,196 @@
+package v3
+
+import (
+	"fmt"
+	"strings"
+)
+
+// List of supported command operators.
+const (
+
+	// Commands that support arguments and max length constraint.
+
+	CMD_REQ              = "REQ"
+	CMD_RESP             = "RESP"
+	CMD_TRAILER          = "TRAILER"
+	CMD_DYNAMIC_METADATA = "DYNAMIC_METADATA"
+	CMD_FILTER_STATE     = "FILTER_STATE"
+	CMD_START_TIME       = "START_TIME"
+
+	// Commands that differ for HTTP and TCP log entries.
+
+	CMD_BYTES_RECEIVED        = "BYTES_RECEIVED"
+	CMD_BYTES_SENT            = "BYTES_SENT"
+	CMD_PROTOCOL              = "PROTOCOL"
+	CMD_RESPONSE_CODE         = "RESPONSE_CODE"
+	CMD_RESPONSE_CODE_DETAILS = "RESPONSE_CODE_DETAILS"
+	CMD_REQUEST_DURATION      = "REQUEST_DURATION"
+	CMD_RESPONSE_DURATION     = "RESPONSE_DURATION"
+	CMD_RESPONSE_TX_DURATION  = "RESPONSE_TX_DURATION"
+	CMD_GRPC_STATUS           = "GRPC_STATUS"
+
+	// Commands that are the same for HTTP and TCP log entries.
+
+	CMD_DURATION                                      = "DURATION"
+	CMD_RESPONSE_FLAGS                                = "RESPONSE_FLAGS"
+	CMD_UPSTREAM_HOST                                 = "UPSTREAM_HOST"
+	CMD_UPSTREAM_CLUSTER                              = "UPSTREAM_CLUSTER"
+	CMD_UPSTREAM_LOCAL_ADDRESS                        = "UPSTREAM_LOCAL_ADDRESS"
+	CMD_DOWNSTREAM_LOCAL_ADDRESS                      = "DOWNSTREAM_LOCAL_ADDRESS"
+	CMD_DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT         = "DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT"
+	CMD_DOWNSTREAM_REMOTE_ADDRESS                     = "DOWNSTREAM_REMOTE_ADDRESS"
+	CMD_DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT        = "DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT"
+	CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS              = "DOWNSTREAM_DIRECT_REMOTE_ADDRESS"
+	CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT = "DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT"
+	CMD_REQUESTED_SERVER_NAME                         = "REQUESTED_SERVER_NAME"
+	CMD_ROUTE_NAME                                    = "ROUTE_NAME"
+	CMD_DOWNSTREAM_PEER_URI_SAN                       = "DOWNSTREAM_PEER_URI_SAN"
+	CMD_DOWNSTREAM_LOCAL_URI_SAN                      = "DOWNSTREAM_LOCAL_URI_SAN"
+	CMD_DOWNSTREAM_PEER_SUBJECT                       = "DOWNSTREAM_PEER_SUBJECT"
+	CMD_DOWNSTREAM_LOCAL_SUBJECT                      = "DOWNSTREAM_LOCAL_SUBJECT"
+	CMD_DOWNSTREAM_TLS_SESSION_ID                     = "DOWNSTREAM_TLS_SESSION_ID"
+	CMD_DOWNSTREAM_TLS_CIPHER                         = "DOWNSTREAM_TLS_CIPHER"
+	CMD_DOWNSTREAM_TLS_VERSION                        = "DOWNSTREAM_TLS_VERSION"
+	CMD_UPSTREAM_TRANSPORT_FAILURE_REASON             = "UPSTREAM_TRANSPORT_FAILURE_REASON"
+
+	// Commands that are the same for HTTP and TCP log entries
+	// but have no equivalent data in GrpcAccessLog.
+
+	CMD_DOWNSTREAM_PEER_FINGERPRINT_256 = "DOWNSTREAM_PEER_FINGERPRINT_256"
+	CMD_DOWNSTREAM_PEER_SERIAL          = "DOWNSTREAM_PEER_SERIAL"
+	CMD_DOWNSTREAM_PEER_ISSUER          = "DOWNSTREAM_PEER_ISSUER"
+	CMD_DOWNSTREAM_PEER_CERT            = "DOWNSTREAM_PEER_CERT"
+	CMD_DOWNSTREAM_PEER_CERT_V_START    = "DOWNSTREAM_PEER_CERT_V_START"
+	CMD_DOWNSTREAM_PEER_CERT_V_END      = "DOWNSTREAM_PEER_CERT_V_END"
+	CMD_HOSTNAME                        = "HOSTNAME"
+
+	// Commands unique to Kuma.
+
+	CMD_KUMA_SOURCE_ADDRESS              = "KUMA_SOURCE_ADDRESS"
+	CMD_KUMA_SOURCE_ADDRESS_WITHOUT_PORT = "KUMA_SOURCE_ADDRESS_WITHOUT_PORT"
+	CMD_KUMA_SOURCE_SERVICE              = "KUMA_SOURCE_SERVICE"
+	CMD_KUMA_DESTINATION_SERVICE         = "KUMA_DESTINATION_SERVICE"
+	CMD_KUMA_MESH                        = "KUMA_MESH"
+	CMD_KUMA_TRAFFIC_DIRECTION           = "KUMA_TRAFFIC_DIRECTION"
+)
+
+// CommandOperatorDescriptor represents a descriptor of an Envoy access log command operator.
+//
+// See https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log#command-operators
+type CommandOperatorDescriptor string
+
+// String returns the reference name of an Envoy access log command operator
+// as it appears on https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log#command-operators
+func (o CommandOperatorDescriptor) String() string {
+	switch string(o) {
+	case CMD_REQ:
+		return "%REQ(X?Y):Z%"
+	case CMD_RESP:
+		return "%RESP(X?Y):Z%"
+	case CMD_TRAILER:
+		return "%TRAILER(X?Y):Z%"
+	case CMD_DYNAMIC_METADATA:
+		return "%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%"
+	case CMD_FILTER_STATE:
+		return "%FILTER_STATE(KEY):Z%"
+	case CMD_START_TIME:
+		return "%START_TIME%"
+	case CMD_BYTES_RECEIVED:
+		return "%BYTES_RECEIVED%"
+	case CMD_BYTES_SENT:
+		return "%BYTES_SENT%"
+	case CMD_PROTOCOL:
+		return "%PROTOCOL%"
+	case CMD_RESPONSE_CODE:
+		return "%RESPONSE_CODE%"
+	case CMD_RESPONSE_CODE_DETAILS:
+		return "%RESPONSE_CODE_DETAILS%"
+	case CMD_REQUEST_DURATION:
+		return "%REQUEST_DURATION%"
+	case CMD_RESPONSE_DURATION:
+		return "%RESPONSE_DURATION%"
+	case CMD_RESPONSE_TX_DURATION:
+		return "%RESPONSE_TX_DURATION%"
+	case CMD_DURATION:
+		return "%DURATION%"
+	case CMD_RESPONSE_FLAGS:
+		return "%RESPONSE_FLAGS%"
+	case CMD_UPSTREAM_HOST:
+		return "%UPSTREAM_HOST%"
+	case CMD_UPSTREAM_CLUSTER:
+		return "%UPSTREAM_CLUSTER%"
+	case CMD_UPSTREAM_LOCAL_ADDRESS:
+		return "%UPSTREAM_LOCAL_ADDRESS%"
+	case CMD_DOWNSTREAM_LOCAL_ADDRESS:
+		return "%DOWNSTREAM_LOCAL_ADDRESS%"
+	case CMD_DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT:
+		return "%DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT%"
+	case CMD_DOWNSTREAM_REMOTE_ADDRESS:
+		return "%DOWNSTREAM_REMOTE_ADDRESS%"
+	case CMD_DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT:
+		return "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%"
+	case CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS:
+		return "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%"
+	case CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT:
+		return "%DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT%"
+	case CMD_REQUESTED_SERVER_NAME:
+		return "%REQUESTED_SERVER_NAME%"
+	case CMD_ROUTE_NAME:
+		return "%ROUTE_NAME%"
+	case CMD_DOWNSTREAM_PEER_URI_SAN:
+		return "%DOWNSTREAM_PEER_URI_SAN%"
+	case CMD_DOWNSTREAM_LOCAL_URI_SAN:
+		return "%DOWNSTREAM_LOCAL_URI_SAN%"
+	case CMD_DOWNSTREAM_PEER_SUBJECT:
+		return "%DOWNSTREAM_PEER_SUBJECT%"
+	case CMD_DOWNSTREAM_LOCAL_SUBJECT:
+		return "%DOWNSTREAM_LOCAL_SUBJECT%"
+	case CMD_DOWNSTREAM_TLS_SESSION_ID:
+		return "%DOWNSTREAM_TLS_SESSION_ID%"
+	case CMD_DOWNSTREAM_TLS_CIPHER:
+		return "%DOWNSTREAM_TLS_CIPHER%"
+	case CMD_DOWNSTREAM_TLS_VERSION:
+		return "%DOWNSTREAM_TLS_VERSION%"
+	case CMD_UPSTREAM_TRANSPORT_FAILURE_REASON:
+		return "%UPSTREAM_TRANSPORT_FAILURE_REASON%"
+	case CMD_DOWNSTREAM_PEER_FINGERPRINT_256:
+		return "%DOWNSTREAM_PEER_FINGERPRINT_256%"
+	case CMD_DOWNSTREAM_PEER_SERIAL:
+		return "%DOWNSTREAM_PEER_SERIAL%"
+	case CMD_DOWNSTREAM_PEER_ISSUER:
+		return "%DOWNSTREAM_PEER_ISSUER%"
+	case CMD_DOWNSTREAM_PEER_CERT:
+		return "%DOWNSTREAM_PEER_CERT%"
+	case CMD_DOWNSTREAM_PEER_CERT_V_START:
+		return "%DOWNSTREAM_PEER_CERT_V_START%"
+	case CMD_DOWNSTREAM_PEER_CERT_V_END:
+		return "%DOWNSTREAM_PEER_CERT_V_END%"
+	case CMD_HOSTNAME:
+		return "%HOSTNAME%"
+	case CMD_KUMA_SOURCE_ADDRESS:
+		return "%KUMA_SOURCE_ADDRESS%"
+	case CMD_KUMA_SOURCE_ADDRESS_WITHOUT_PORT:
+		return "%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%"
+	case CMD_KUMA_SOURCE_SERVICE:
+		return "%KUMA_SOURCE_SERVICE%"
+	case CMD_KUMA_DESTINATION_SERVICE:
+		return "%KUMA_DESTINATION_SERVICE%"
+	case CMD_KUMA_MESH:
+		return "%KUMA_MESH%"
+	case CMD_KUMA_TRAFFIC_DIRECTION:
+		return "%KUMA_TRAFFIC_DIRECTION%"
+	case CMD_GRPC_STATUS:
+		return "%CMD_GRPC_STATUS%"
+	default:
+		return fmt.Sprintf("%%%s%%", string(o))
+	}
+}
+
+// IsPlaceholder returns true if this command is a placeholder
+// that must be resolved before configuring Envoy with that format string.
+// E.g., %KUMA_SOURCE_ADDRESS%, %KUMA_SOURCE_ADDRESS_WITHOUT_PORT%,
+// %KUMA_SOURCE_SERVICE% and %KUMA_DESTINATION_SERVICE%
+// are examples of such placeholders.
+func (o CommandOperatorDescriptor) IsPlaceholder() bool {
+	return strings.HasPrefix(string(o), "KUMA_")
+}
diff --git a/pkg/envoy/accesslog/v3/doc.go b/pkg/envoy/accesslog/v3/doc.go
new file mode 100644
index 000000000..f8e301122
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/doc.go
@@ -0,0 +1,23 @@
+/*
+Package accesslog replicates access log format supported by Envoy.
+
+In order to allow users of Kuma to reuse the same access log format strings
+both in file logs and TCP logs, we need to have native support for
+Envoy access log command syntax.
+
+Use ParseFormat() function to parse a format string.
+
+Use HttpLogEntryFormatter interface to format an HTTP log entry.
+
+Use TcpLogEntryFormatter interface to format a TCP log entry.
+
+Use HttpLogConfigurer interface to configure `envoy.access_loggers.http_grpc` filter.
+
+Use TcpLogConfigurer interface to configure `envoy.tcp_grpc_access_log` filter.
+
+The initial implementation is missing the following features:
+1. `%START_TIME%` commands ignore the user-defined format string
+2. `%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%` commands return a stub value
+3. `%FILTER_STATE(KEY):Z%` commands return a stub value
+*/
+package v3
diff --git a/pkg/envoy/accesslog/v3/dynamic_metadata_operator.go b/pkg/envoy/accesslog/v3/dynamic_metadata_operator.go
new file mode 100644
index 000000000..90238f628
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/dynamic_metadata_operator.go
@@ -0,0 +1,59 @@
+package v3
+
+import (
+	"strconv"
+	"strings"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// DynamicMetadataOperator represents a `%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%` command operator.
+type DynamicMetadataOperator struct {
+	FilterNamespace string
+	Path            []string
+	MaxLength       int
+}
+
+func (f *DynamicMetadataOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f *DynamicMetadataOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f *DynamicMetadataOperator) format(entry *accesslog_data.AccessLogCommon) (string, error) {
+	// TODO(yskopets): implement
+	return "UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)", nil
+}
+
+func (f *DynamicMetadataOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	// has no effect on HttpGrpcAccessLogConfig
+	return nil
+}
+
+func (f *DynamicMetadataOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f *DynamicMetadataOperator) String() string {
+	var builder []string
+	builder = append(builder, "%DYNAMIC_METADATA(")
+	builder = append(builder, f.FilterNamespace)
+	if len(f.Path) > 0 {
+		for _, segment := range f.Path {
+			builder = append(builder, ":")
+			builder = append(builder, segment)
+		}
+	}
+	builder = append(builder, ")")
+	if f.MaxLength != 0 {
+		builder = append(builder, ":")
+		builder = append(builder, strconv.FormatInt(int64(f.MaxLength), 10))
+	}
+	builder = append(builder, "%")
+	return strings.Join(builder, "")
+}
diff --git a/pkg/envoy/accesslog/v3/field_operator.go b/pkg/envoy/accesslog/v3/field_operator.go
new file mode 100644
index 000000000..33f41fc5d
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/field_operator.go
@@ -0,0 +1,326 @@
+package v3
+
+import (
+	"fmt"
+	"net"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/golang/protobuf/ptypes"
+	"github.com/golang/protobuf/ptypes/duration"
+	"github.com/golang/protobuf/ptypes/wrappers"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+
+	envoy_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+)
+
+const (
+	includePort = true
+	excludePort = false
+)
+
+// FieldOperator represents a simple field command operator,
+// such as `%BYTES_RECEIVED%` or `%PROTOCOL%`.
+type FieldOperator string
+
+// String returns the canonical representation of this access log fragment.
+func (f FieldOperator) String() string {
+	return CommandOperatorDescriptor(f).String()
+}
+
+func (f FieldOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	// has no effect on HttpGrpcAccessLogConfig
+	return nil
+}
+
+func (f FieldOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+func (f FieldOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	switch f {
+	case CMD_BYTES_RECEIVED:
+		return f.formatUint(entry.GetRequest().GetRequestBodyBytes())
+	case CMD_BYTES_SENT:
+		return f.formatUint(entry.GetResponse().GetResponseBodyBytes())
+	case CMD_PROTOCOL:
+		return f.formatHttpVersion(entry.GetProtocolVersion())
+	case CMD_RESPONSE_CODE:
+		return f.formatUint(uint64(entry.GetResponse().GetResponseCode().GetValue()))
+	case CMD_RESPONSE_CODE_DETAILS:
+		return entry.GetResponse().GetResponseCodeDetails(), nil
+	case CMD_REQUEST_DURATION:
+		return f.formatDuration(entry.GetCommonProperties().GetTimeToLastRxByte())
+	case CMD_RESPONSE_DURATION:
+		return f.formatDuration(entry.GetCommonProperties().GetTimeToFirstUpstreamRxByte())
+	case CMD_RESPONSE_TX_DURATION:
+		return f.formatDurationDelta(entry.GetCommonProperties().GetTimeToLastDownstreamTxByte(), entry.GetCommonProperties().GetTimeToFirstUpstreamRxByte())
+	case CMD_GRPC_STATUS:
+		return f.formatgRPCStatus(entry.GetResponse())
+	default:
+		return f.formatAccessLogCommon(entry.GetCommonProperties())
+	}
+}
+
+func (f FieldOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	switch f {
+	case CMD_BYTES_RECEIVED:
+		return f.formatUint(entry.GetConnectionProperties().GetReceivedBytes())
+	case CMD_BYTES_SENT:
+		return f.formatUint(entry.GetConnectionProperties().GetSentBytes())
+	case CMD_PROTOCOL:
+		return "", nil // replicate Envoy's behaviour
+	case CMD_RESPONSE_CODE:
+		return "0", nil // replicate Envoy's behaviour
+	case CMD_RESPONSE_CODE_DETAILS:
+		return "", nil // replicate Envoy's behaviour
+	case CMD_REQUEST_DURATION:
+		return "", nil // replicate Envoy's behaviour
+	case CMD_RESPONSE_DURATION:
+		return "", nil // replicate Envoy's behaviour
+	case CMD_RESPONSE_TX_DURATION:
+		return "", nil // replicate Envoy's behaviour
+	case CMD_GRPC_STATUS:
+		return "", nil // replace Envoy's behaviour
+	default:
+		return f.formatAccessLogCommon(entry.GetCommonProperties())
+	}
+}
+
+func (f FieldOperator) formatAccessLogCommon(entry *accesslog_data.AccessLogCommon) (string, error) {
+	switch f {
+	case CMD_UPSTREAM_TRANSPORT_FAILURE_REASON:
+		return entry.GetUpstreamTransportFailureReason(), nil
+	case CMD_DURATION:
+		return f.formatDuration(entry.GetTimeToLastDownstreamTxByte())
+	case CMD_RESPONSE_FLAGS:
+		return f.formatResponseFlags(entry.GetResponseFlags())
+	case CMD_UPSTREAM_HOST:
+		return f.formatAddress(entry.GetUpstreamRemoteAddress(), includePort)
+	case CMD_UPSTREAM_CLUSTER:
+		return entry.GetUpstreamCluster(), nil
+	case CMD_UPSTREAM_LOCAL_ADDRESS:
+		return f.formatAddress(entry.GetUpstreamLocalAddress(), includePort)
+	case CMD_DOWNSTREAM_LOCAL_ADDRESS:
+		return f.formatAddress(entry.GetDownstreamLocalAddress(), includePort)
+	case CMD_DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT:
+		return f.formatAddress(entry.GetDownstreamLocalAddress(), excludePort)
+	case CMD_DOWNSTREAM_REMOTE_ADDRESS:
+		return f.formatAddress(entry.GetDownstreamRemoteAddress(), includePort)
+	case CMD_DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT:
+		return f.formatAddress(entry.GetDownstreamRemoteAddress(), excludePort)
+	case CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS:
+		return f.formatAddress(entry.GetDownstreamDirectRemoteAddress(), includePort)
+	case CMD_DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT:
+		return f.formatAddress(entry.GetDownstreamDirectRemoteAddress(), excludePort)
+	case CMD_REQUESTED_SERVER_NAME:
+		return entry.GetTlsProperties().GetTlsSniHostname(), nil
+	case CMD_ROUTE_NAME:
+		return entry.GetRouteName(), nil
+	case CMD_DOWNSTREAM_PEER_URI_SAN:
+		return f.formatUriSans(entry.GetTlsProperties().GetPeerCertificateProperties().GetSubjectAltName())
+	case CMD_DOWNSTREAM_LOCAL_URI_SAN:
+		return f.formatUriSans(entry.GetTlsProperties().GetLocalCertificateProperties().GetSubjectAltName())
+	case CMD_DOWNSTREAM_PEER_SUBJECT:
+		return entry.GetTlsProperties().GetPeerCertificateProperties().GetSubject(), nil
+	case CMD_DOWNSTREAM_LOCAL_SUBJECT:
+		return entry.GetTlsProperties().GetLocalCertificateProperties().GetSubject(), nil
+	case CMD_DOWNSTREAM_TLS_SESSION_ID:
+		return entry.GetTlsProperties().GetTlsSessionId(), nil
+	case CMD_DOWNSTREAM_TLS_CIPHER:
+		return f.formatTlsCipherSuite(entry.GetTlsProperties().GetTlsCipherSuite())
+	case CMD_DOWNSTREAM_TLS_VERSION:
+		return f.formatTlsVersion(entry.GetTlsProperties().GetTlsVersion())
+	case CMD_DOWNSTREAM_PEER_FINGERPRINT_256,
+		CMD_DOWNSTREAM_PEER_SERIAL,
+		CMD_DOWNSTREAM_PEER_ISSUER,
+		CMD_DOWNSTREAM_PEER_CERT,
+		CMD_DOWNSTREAM_PEER_CERT_V_START,
+		CMD_DOWNSTREAM_PEER_CERT_V_END,
+		CMD_HOSTNAME:
+		fallthrough // these fields have no equivalent data in GrpcAccessLog
+	default:
+		// make it clear to the user what is happening
+		return fmt.Sprintf("UNSUPPORTED_COMMAND(%s)", f), nil
+	}
+}
+
+func (f FieldOperator) formatUint(value uint64) (string, error) {
+	return strconv.FormatUint(value, 10), nil
+}
+
+func (f FieldOperator) formatInt(value int64) (string, error) {
+	return strconv.FormatInt(value, 10), nil
+}
+
+func (f FieldOperator) formatDuration(dur *duration.Duration) (string, error) {
+	if dur == nil {
+		return "", nil
+	}
+	durNanos, err := ptypes.Duration(dur)
+	if err != nil {
+		return "", err
+	}
+	return f.formatInt(int64(durNanos / time.Millisecond))
+}
+
+func (f FieldOperator) formatDurationDelta(outer *duration.Duration, inner *duration.Duration) (string, error) {
+	if outer == nil || inner == nil {
+		return "", nil
+	}
+	outerNanos, err := ptypes.Duration(outer)
+	if err != nil {
+		return "", err
+	}
+	innerNanos, err := ptypes.Duration(inner)
+	if err != nil {
+		return "", err
+	}
+	return f.formatInt(int64((outerNanos - innerNanos) / time.Millisecond))
+}
+
+func (f FieldOperator) formatHttpVersion(value accesslog_data.HTTPAccessLogEntry_HTTPVersion) (string, error) {
+	switch value {
+	case accesslog_data.HTTPAccessLogEntry_PROTOCOL_UNSPECIFIED:
+		return "", nil
+	case accesslog_data.HTTPAccessLogEntry_HTTP10:
+		return "HTTP/1.0", nil
+	case accesslog_data.HTTPAccessLogEntry_HTTP11:
+		return "HTTP/1.1", nil
+	case accesslog_data.HTTPAccessLogEntry_HTTP2:
+		return "HTTP/2", nil
+	case accesslog_data.HTTPAccessLogEntry_HTTP3:
+		return "HTTP/3", nil
+	default:
+		return value.String(), nil
+	}
+}
+
+func (f FieldOperator) formatResponseFlags(flags *accesslog_data.ResponseFlags) (string, error) {
+	values := make([]string, 0)
+	if flags.GetFailedLocalHealthcheck() {
+		values = append(values, ResponseFlagFailedLocalHealthCheck)
+	}
+	if flags.GetNoHealthyUpstream() {
+		values = append(values, ResponseFlagNoHealthyUpstream)
+	}
+	if flags.GetUpstreamRequestTimeout() {
+		values = append(values, ResponseFlagUpstreamRequestTimeout)
+	}
+	if flags.GetLocalReset() {
+		values = append(values, ResponseFlagLocalReset)
+	}
+	if flags.GetUpstreamRemoteReset() {
+		values = append(values, ResponseFlagUpstreamRemoteReset)
+	}
+	if flags.GetUpstreamConnectionFailure() {
+		values = append(values, ResponseFlagUpstreamConnectionFailure)
+	}
+	if flags.GetUpstreamConnectionTermination() {
+		values = append(values, ResponseFlagUpstreamConnectionTermination)
+	}
+	if flags.GetUpstreamOverflow() {
+		values = append(values, ResponseFlagUpstreamOverflow)
+	}
+	if flags.GetNoRouteFound() {
+		values = append(values, ResponseFlagNoRouteFound)
+	}
+	if flags.GetDelayInjected() {
+		values = append(values, ResponseFlagDelayInjected)
+	}
+	if flags.GetFaultInjected() {
+		values = append(values, ResponseFlagFaultInjected)
+	}
+	if flags.GetRateLimited() {
+		values = append(values, ResponseFlagRateLimited)
+	}
+	if flags.GetUnauthorizedDetails().GetReason() == accesslog_data.ResponseFlags_Unauthorized_EXTERNAL_SERVICE {
+		values = append(values, ResponseFlagUnauthorizedExternalService)
+	}
+	if flags.GetRateLimitServiceError() {
+		values = append(values, ResponseFlagRatelimitServiceError)
+	}
+	if flags.GetDownstreamConnectionTermination() {
+		values = append(values, ResponseFlagDownstreamConnectionTermination)
+	}
+	if flags.GetUpstreamRetryLimitExceeded() {
+		values = append(values, ResponseFlagUpstreamRetryLimitExceeded)
+	}
+	if flags.GetStreamIdleTimeout() {
+		values = append(values, ResponseFlagStreamIdleTimeout)
+	}
+	if flags.GetInvalidEnvoyRequestHeaders() {
+		values = append(values, ResponseFlagInvalidEnvoyRequestHeaders)
+	}
+	if flags.GetDownstreamProtocolError() {
+		values = append(values, ResponseFlagDownstreamProtocolError)
+	}
+	return strings.Join(values, ","), nil
+}
+
+func (f FieldOperator) formatAddress(address *envoy_core.Address, includePort bool) (string, error) {
+	switch typ := address.GetAddress().(type) {
+	case *envoy_core.Address_SocketAddress:
+		if includePort {
+			return net.JoinHostPort(typ.SocketAddress.GetAddress(), strconv.FormatUint(uint64(typ.SocketAddress.GetPortValue()), 10)), nil
+		}
+		return typ.SocketAddress.GetAddress(), nil
+	case *envoy_core.Address_Pipe:
+		return typ.Pipe.GetPath(), nil
+	default:
+		return "", nil
+	}
+}
+
+func (f FieldOperator) formatUriSans(sans []*accesslog_data.TLSProperties_CertificateProperties_SubjectAltName) (string, error) {
+	values := make([]string, 0, len(sans))
+	for _, san := range sans {
+		switch typ := san.GetSan().(type) {
+		case *accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri:
+			values = append(values, typ.Uri)
+		}
+	}
+	return strings.Join(values, ","), nil
+}
+
+func (f FieldOperator) formatTlsCipherSuite(value *wrappers.UInt32Value) (string, error) {
+	if value == nil || value.GetValue() == 0xFFFF {
+		return "", nil
+	}
+	return TlsCipherSuite(value.GetValue()).String(), nil
+}
+
+func (f FieldOperator) formatTlsVersion(value accesslog_data.TLSProperties_TLSVersion) (string, error) {
+	switch value {
+	case accesslog_data.TLSProperties_VERSION_UNSPECIFIED:
+		return "", nil
+	case accesslog_data.TLSProperties_TLSv1:
+		return "TLSv1", nil
+	case accesslog_data.TLSProperties_TLSv1_1:
+		return "TLSv1.1", nil
+	case accesslog_data.TLSProperties_TLSv1_2:
+		return "TLSv1.2", nil
+	case accesslog_data.TLSProperties_TLSv1_3:
+		return "TLSv1.3", nil
+	default:
+		return value.String(), nil
+	}
+}
+
+func (f FieldOperator) formatgRPCStatus(responseProperties *accesslog_data.HTTPResponseProperties) (string, error) {
+	responseTrailers := responseProperties.GetResponseTrailers()
+	value, ok := responseTrailers["grpc-status"]
+	if !ok {
+		return "", nil
+	}
+	switch value {
+	case "0":
+		return "OK", nil
+	default:
+		return "InvalidCode", nil
+	}
+}
diff --git a/pkg/envoy/accesslog/v3/filter_state_operator.go b/pkg/envoy/accesslog/v3/filter_state_operator.go
new file mode 100644
index 000000000..dcf851d4f
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/filter_state_operator.go
@@ -0,0 +1,69 @@
+package v3
+
+import (
+	"strconv"
+	"strings"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// FilterStateOperator represents a `%FILTER_STATE(KEY):Z%` command operator.
+type FilterStateOperator struct {
+	Key       string
+	MaxLength int
+}
+
+func (f *FilterStateOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f *FilterStateOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f *FilterStateOperator) format(entry *accesslog_data.AccessLogCommon) (string, error) {
+	// TODO(yskopets): implement
+	return "UNSUPPORTED_COMMAND(%FILTER_STATE(KEY):Z%)", nil
+}
+
+func (f *FilterStateOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	if objects := f.appendToSet(config.GetCommonConfig().GetFilterStateObjectsToLog()); objects != nil {
+		if config.CommonConfig == nil {
+			config.CommonConfig = &accesslog_config.CommonGrpcAccessLogConfig{}
+		}
+		config.CommonConfig.FilterStateObjectsToLog = objects
+	}
+	return nil
+}
+
+func (f *FilterStateOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	if objects := f.appendToSet(config.GetCommonConfig().GetFilterStateObjectsToLog()); objects != nil {
+		if config.CommonConfig == nil {
+			config.CommonConfig = &accesslog_config.CommonGrpcAccessLogConfig{}
+		}
+		config.CommonConfig.FilterStateObjectsToLog = objects
+	}
+	return nil
+}
+
+func (f *FilterStateOperator) appendToSet(dest []string) []string {
+	if f.Key == "" {
+		return dest
+	}
+	return stringList{f.Key}.AppendToSet(dest)
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f *FilterStateOperator) String() string {
+	var builder []string
+	builder = append(builder, "%FILTER_STATE(")
+	builder = append(builder, f.Key)
+	builder = append(builder, ")")
+	if f.MaxLength != 0 {
+		builder = append(builder, ":")
+		builder = append(builder, strconv.FormatInt(int64(f.MaxLength), 10))
+	}
+	builder = append(builder, "%")
+	return strings.Join(builder, "")
+}
diff --git a/pkg/envoy/accesslog/v3/format.go b/pkg/envoy/accesslog/v3/format.go
new file mode 100644
index 000000000..b8b8938a5
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/format.go
@@ -0,0 +1,95 @@
+package v3
+
+import (
+	"strings"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+const (
+	unspecifiedValue = "-" // to replicate Envoy's behaviour
+)
+
+// AccessLogFormat represents the entire access log format string.
+type AccessLogFormat struct {
+	Fragments []AccessLogFragment
+}
+
+func (f *AccessLogFormat) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	values := make([]string, len(f.Fragments))
+	for i, fragment := range f.Fragments {
+		value, err := fragment.FormatHttpLogEntry(entry)
+		if err != nil {
+			return "", err
+		}
+		if value == "" {
+			value = unspecifiedValue // to replicate Envoy's behaviour
+		}
+		values[i] = value
+	}
+	return strings.Join(values, ""), nil
+}
+
+func (f *AccessLogFormat) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	values := make([]string, len(f.Fragments))
+	for i, fragment := range f.Fragments {
+		value, err := fragment.FormatTcpLogEntry(entry)
+		if err != nil {
+			return "", err
+		}
+		if value == "" {
+			value = unspecifiedValue // to replicate Envoy's behaviour
+		}
+		values[i] = value
+	}
+	return strings.Join(values, ""), nil
+}
+
+func (f *AccessLogFormat) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	for _, fragment := range f.Fragments {
+		if err := fragment.ConfigureHttpLog(config); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (f *AccessLogFormat) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	for _, fragment := range f.Fragments {
+		if err := fragment.ConfigureTcpLog(config); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (f *AccessLogFormat) Interpolate(variables InterpolationVariables) (*AccessLogFormat, error) {
+	newFragments := make([]AccessLogFragment, len(f.Fragments))
+	interpolated := false
+	for i, fragment := range f.Fragments {
+		if interpolator, ok := fragment.(AccessLogFragmentInterpolator); ok {
+			newFragment, err := interpolator.Interpolate(variables)
+			if err != nil {
+				return nil, err
+			}
+			newFragments[i] = newFragment
+			interpolated = interpolated || newFragment != fragment
+		} else {
+			newFragments[i] = fragment
+		}
+	}
+	if interpolated {
+		return &AccessLogFormat{Fragments: newFragments}, nil
+	}
+	return f, nil
+}
+
+// String returns the canonical representation of this format string.
+func (f *AccessLogFormat) String() string {
+	fragments := make([]string, len(f.Fragments))
+	for i, fragment := range f.Fragments {
+		fragments[i] = fragment.String()
+	}
+	return strings.Join(fragments, "")
+}
diff --git a/pkg/envoy/accesslog/v3/format_parser.go b/pkg/envoy/accesslog/v3/format_parser.go
new file mode 100644
index 000000000..e1bcfe135
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/format_parser.go
@@ -0,0 +1,224 @@
+package v3
+
+import (
+	"regexp"
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+var (
+	commandWithArgsRE = regexp.MustCompile(`^%(?P<command>[A-Z_0-9]+)(?:\((?P<args>[^\)]*)\))?(?:[:](?P<limit>[0-9]+))?%`)
+	newlineRE         = regexp.MustCompile(`[\x00\r\n]`)
+	// TODO(yskopets): no idea how the following regexp correlates with the comment
+	// "The formatted string may be destined for a header, and should not contain invalid characters {NUL, LR, CF}."
+	startTimeNewlineRE = regexp.MustCompile(`%[-_0^#]*[1-9]*n`)
+)
+
+// ValidateFormat validates whether a given format string is valid.
+func ValidateFormat(format string) error {
+	_, err := ParseFormat(format)
+	return err
+}
+
+var parser = formatParser{}
+
+// ParseFormat parses a given format string.
+//
+// The returned object can be used for multiple purposes, i.e.
+//   1. To verify that access log format string is valid
+//   2. To adjust configuration of `envoy.access_loggers.http_grpc` and `envoy.tcp_grpc_access_log`
+//      according to the format string, e.g. to capture additional HTTP headers
+//   3. To format a given HTTP or TCP log entry according to the format string
+//   4. To bind `%KUMA_*%` placeholders to concrete context-dependent values
+//   5. To render back into the format string, e.g.
+//      after `%KUMA_*%` placeholders have been bound to concrete context-dependent values
+func ParseFormat(format string) (*AccessLogFormat, error) {
+	return parser.Parse(format)
+}
+
+type formatParser struct{}
+
+func (p formatParser) Parse(format string) (_ *AccessLogFormat, err error) {
+	defer func() {
+		if err != nil {
+			err = errors.Wrap(err, "format string is not valid")
+		}
+	}()
+
+	var textLiteralStart = -1
+	var fragments []AccessLogFragment
+
+	for pos := 0; pos < len(format); pos++ {
+		if format[pos] == '%' {
+			if textLiteralStart > -1 {
+				fragments = append(fragments, TextSpan(format[textLiteralStart:pos]))
+				textLiteralStart = -1
+			}
+
+			tail := format[pos:]
+			match := commandWithArgsRE.FindStringSubmatch(tail)
+			if match == nil {
+				return nil, errors.Errorf("expected a command operator to start at position %d, instead got: %q", pos+1, tail)
+			}
+			token, command, args, limit, err := p.splitMatch(match)
+			if err != nil {
+				return nil, err
+			}
+			operator, err := p.parseCommandOperator(token, command, args, limit)
+			if err != nil {
+				return nil, err
+			}
+			fragments = append(fragments, operator)
+			pos += len(token) - 1
+		} else if textLiteralStart < 0 {
+			textLiteralStart = pos
+		}
+	}
+
+	if textLiteralStart >= 0 {
+		fragments = append(fragments, TextSpan(format[textLiteralStart:]))
+	}
+
+	return &AccessLogFormat{Fragments: fragments}, nil
+}
+
+func (p formatParser) splitMatch(match []string) (token string, command string, args string, limit string, err error) {
+	if len(match) != 4 {
+		return "", "", "", "", errors.Errorf("expected a command operator that consists of a command, args and limit, got %q", match)
+	}
+	return match[0], match[1], match[2], match[3], nil
+}
+
+func (p formatParser) parseCommandOperator(token, command, args, limit string) (AccessLogFragment, error) {
+	switch command {
+	case CMD_REQ:
+		header, altHeader, maxLen, err := p.parseHeaderOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return &RequestHeaderOperator{HeaderFormatter{Header: header, AltHeader: altHeader, MaxLength: maxLen}}, nil
+	case CMD_RESP:
+		header, altHeader, maxLen, err := p.parseHeaderOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return &ResponseHeaderOperator{HeaderFormatter{Header: header, AltHeader: altHeader, MaxLength: maxLen}}, nil
+	case CMD_TRAILER:
+		header, altHeader, maxLen, err := p.parseHeaderOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return &ResponseTrailerOperator{HeaderFormatter{Header: header, AltHeader: altHeader, MaxLength: maxLen}}, nil
+	case CMD_DYNAMIC_METADATA:
+		namespace, path, maxLen, err := p.parseDynamicMetadataOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return &DynamicMetadataOperator{FilterNamespace: namespace, Path: path, MaxLength: maxLen}, nil
+	case CMD_FILTER_STATE:
+		key, maxLen, err := p.parseFilterStateOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return &FilterStateOperator{Key: key, MaxLength: maxLen}, nil
+	case CMD_START_TIME:
+		format, err := p.parseStartTimeOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		return StartTimeOperator(format), nil
+	default:
+		field, err := p.parseFieldOperator(token, command, args, limit)
+		if err != nil {
+			return nil, err
+		}
+		if CommandOperatorDescriptor(field).IsPlaceholder() {
+			return Placeholder(field), nil
+		}
+		return FieldOperator(field), nil
+	}
+}
+
+func (p formatParser) parseHeaderOperator(token, command, args, limit string) (header string, altHeader string, maxLen int, err error) {
+	if p.hasNoArguments(token, command, args, limit) {
+		return "", "", 0, errors.Errorf(`command %q requires a header and optional alternative header names as its arguments, instead got %q`, CommandOperatorDescriptor(command), token)
+	}
+	header, altHeaders, maxLen, err := p.parseOperator(token, args, limit, "?")
+	if err != nil {
+		return "", "", 0, err
+	}
+	if len(altHeaders) > 1 {
+		return "", "", 0, errors.Errorf("more than 1 alternative header specified in %q", token)
+	}
+	if len(altHeaders) > 0 {
+		altHeader = altHeaders[0]
+	}
+	// The main and alternative header should not contain invalid characters {NUL, LR, CF}.
+	if newlineRE.MatchString(header) || newlineRE.MatchString(altHeader) {
+		return "", "", 0, errors.Errorf("header name contains a newline in %q", token)
+	}
+	// apparently, Envoy allows both `Header` and `AltHeader` to be empty
+	return strings.ToLower(header), strings.ToLower(altHeader), maxLen, nil // Envoy emits log entries with all headers in lower case
+}
+
+func (p formatParser) parseDynamicMetadataOperator(token, command, args, limit string) (namespace string, path []string, maxLen int, err error) {
+	namespace, path, maxLen, err = p.parseOperator(token, args, limit, ":")
+	if err != nil {
+		return "", nil, 0, err
+	}
+	if p.hasNoArguments(token, command, args, limit) {
+		return "", nil, 0, errors.Errorf(`command %q requires a filter namespace and optional path as its arguments, instead got %q`, CommandOperatorDescriptor(command), token)
+	}
+	return namespace, path, maxLen, err
+}
+
+func (p formatParser) parseFilterStateOperator(token, command, args, limit string) (key string, maxLen int, err error) {
+	key, _, maxLen, err = p.parseOperator(token, args, limit, "")
+	if err != nil {
+		return "", 0, err
+	}
+	if p.hasNoArguments(token, command, args, limit) || key == "" {
+		return "", 0, errors.Errorf(`command %q requires a key as its argument, instead got %q`, CommandOperatorDescriptor(command), token)
+	}
+	return key, maxLen, nil
+}
+
+func (p formatParser) parseStartTimeOperator(token, command, args, limit string) (format string, err error) {
+	// Validate the input specifier here. The formatted string may be destined for a header, and
+	// should not contain invalid characters {NUL, LR, CF}.
+	if startTimeNewlineRE.MatchString(args) {
+		return "", errors.Errorf("start time format string contains a newline in %q", token)
+	}
+	return args, nil
+}
+
+func (p formatParser) parseFieldOperator(token, command, args, limit string) (field string, err error) {
+	if token[1:len(token)-1] != command {
+		return "", errors.Errorf(`command %q doesn't support arguments or max length constraint, instead got %q`, CommandOperatorDescriptor(command), token)
+	}
+	return command, nil
+}
+
+func (p formatParser) parseOperator(token, args, limit string, separator string) (firstArg string, otherArgs []string, maxLen int, err error) {
+	if limit != "" {
+		maxLen, err = strconv.Atoi(limit)
+		if err != nil {
+			return "", nil, 0, errors.Errorf("length must be an integer, instead got %q in %q", limit, token)
+		}
+	}
+	if separator != "" {
+		allArgs := strings.Split(args, separator)
+		firstArg, otherArgs = allArgs[0], allArgs[1:]
+	} else {
+		firstArg = args
+	}
+	return firstArg, otherArgs, maxLen, err
+}
+
+func (p formatParser) hasNoArguments(token, command, args, limit string) bool {
+	return args == "" &&
+		((limit == "" && token[1:len(token)-1] == command) ||
+			(limit != "" && token[1:len(token)-1-(len(limit)+1)] == command))
+}
diff --git a/pkg/envoy/accesslog/v3/header_formatter.go b/pkg/envoy/accesslog/v3/header_formatter.go
new file mode 100644
index 000000000..cc55b3b18
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/header_formatter.go
@@ -0,0 +1,75 @@
+package v3
+
+import (
+	"strconv"
+	"strings"
+)
+
+// Headers represents a set of headers
+// that might include both regular and pseudo headers.
+type Headers interface {
+	Get(name string) (value string, exists bool)
+}
+
+// HeaderMap represents a set of regular headers.
+type HeaderMap map[string]string
+
+func (m HeaderMap) Get(name string) (value string, exists bool) {
+	value, exists = m[name]
+	return
+}
+
+// HeaderFormatter represents reusable formatting logic that is
+// shared by `%REQ(X?Y):Z%`, `%RESP(X?Y):Z%` and `%TRAILER(X?Y):Z%`
+// command operators.
+type HeaderFormatter struct {
+	Header    string
+	AltHeader string
+	MaxLength int
+}
+
+func (f *HeaderFormatter) Format(headers Headers) (string, error) {
+	value, exists := "", false
+	// apparently, Envoy allows both `Header` and `AltHeader` to be empty
+	if f.Header != "" {
+		value, exists = headers.Get(f.Header)
+	}
+	if !exists && f.AltHeader != "" {
+		value, _ = headers.Get(f.AltHeader)
+	}
+	if f.MaxLength > 0 && len(value) > f.MaxLength {
+		return value[:f.MaxLength], nil
+	}
+	return value, nil
+}
+
+func (f *HeaderFormatter) GetOperandHeaders() (headers []string) {
+	// apparently, Envoy allows both `Header` and `AltHeader` to be empty
+	if f.Header != "" {
+		headers = append(headers, f.Header)
+	}
+	if f.AltHeader != "" {
+		headers = append(headers, f.AltHeader)
+	}
+	return headers
+}
+
+// String returns the canonical representation of a header command operator
+// arguments and max length constraint.
+func (f *HeaderFormatter) String() string {
+	var builder []string
+	builder = append(builder, "(")
+	if f.Header != "" || f.AltHeader != "" {
+		builder = append(builder, f.Header)
+		if f.AltHeader != "" {
+			builder = append(builder, "?")
+			builder = append(builder, f.AltHeader)
+		}
+	}
+	builder = append(builder, ")")
+	if f.MaxLength != 0 {
+		builder = append(builder, ":")
+		builder = append(builder, strconv.FormatInt(int64(f.MaxLength), 10))
+	}
+	return strings.Join(builder, "")
+}
diff --git a/pkg/envoy/accesslog/v3/interfaces.go b/pkg/envoy/accesslog/v3/interfaces.go
new file mode 100644
index 000000000..97f2c2664
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/interfaces.go
@@ -0,0 +1,61 @@
+package v3
+
+import (
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// AccessLogFragment represents a fragment of an Envoy access log format string,
+// such as a command operator or a span of plain text.
+//
+// See https://www.envoyproxy.io/docs/envoy/latest/configuration/observability/access_log#command-operators
+type AccessLogFragment interface {
+	HttpLogEntryFormatter
+	TcpLogEntryFormatter
+	HttpLogConfigurer
+	TcpLogConfigurer
+	// String returns the canonical representation of this fragment.
+	String() string
+}
+
+// HttpLogEntryFormatter formats a given HTTP log entry
+// according to the format string.
+type HttpLogEntryFormatter interface {
+	FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error)
+}
+
+// TcpLogEntryFormatter formats a given TCP log entry
+// according to the format string.
+type TcpLogEntryFormatter interface {
+	FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error)
+}
+
+// HttpLogConfigurer adjusts configuration of `envoy.access_loggers.http_grpc`
+// according to the format string, e.g. to capture additional HTTP headers.
+type HttpLogConfigurer interface {
+	ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error
+}
+
+// TcpLogConfigurer adjusts configuration of `envoy.tcp_grpc_access_log`
+// according to the format string, e.g. to capture filter state objects.
+type TcpLogConfigurer interface {
+	ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error
+}
+
+// AccessLogFragmentInterpolator interpolates placeholders
+// added to an access log format string.
+// E.g. %KUMA_SOURCE_SERVICE%, %KUMA_DESTINATION_SERVICE%,
+// %KUMA_SOURCE_ADDRESS% and %KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+// are examples of such placeholders.
+type AccessLogFragmentInterpolator interface {
+	// Interpolate returns an access log fragment with all placeholders resolved.
+	Interpolate(variables InterpolationVariables) (AccessLogFragment, error)
+}
+
+// InterpolationVariables represents a context of Interpolate() operation
+// as a map of variables.
+type InterpolationVariables map[string]string
+
+func (m InterpolationVariables) Get(variable string) string {
+	return m[variable]
+}
diff --git a/pkg/envoy/accesslog/v3/placeholder_fragment.go b/pkg/envoy/accesslog/v3/placeholder_fragment.go
new file mode 100644
index 000000000..7f03e8bd0
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/placeholder_fragment.go
@@ -0,0 +1,43 @@
+package v3
+
+import (
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// Placeholder represents a placeholder added to an access log format string
+// that must be resolved before configuring Envoy with that format string.
+//
+// E.g. %KUMA_SOURCE_SERVICE%, %KUMA_DESTINATION_SERVICE%,
+// %KUMA_SOURCE_ADDRESS% and %KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+// are examples of such placeholders.
+type Placeholder string
+
+func (f Placeholder) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.String(), nil
+}
+
+func (f Placeholder) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return f.String(), nil
+}
+
+func (f Placeholder) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	// has no effect on HttpGrpcAccessLogConfig
+	return nil
+}
+
+func (f Placeholder) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this command operator.
+func (f Placeholder) String() string {
+	return CommandOperatorDescriptor(f).String()
+}
+
+// Interpolate returns an access log fragment with all placeholders resolved.
+func (f Placeholder) Interpolate(variables InterpolationVariables) (AccessLogFragment, error) {
+	value := variables.Get(string(f))
+	return TextSpan(value), nil // turn placeholder into a text literal
+}
diff --git a/pkg/envoy/accesslog/v3/request_header_operator.go b/pkg/envoy/accesslog/v3/request_header_operator.go
new file mode 100644
index 000000000..94f447d81
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/request_header_operator.go
@@ -0,0 +1,120 @@
+package v3
+
+import (
+	"fmt"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+
+	envoy_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+)
+
+const (
+	HeaderMethod             = ":method"
+	HeaderScheme             = ":scheme"
+	HeaderAuthority          = ":authority"
+	HeaderPath               = ":path"
+	HeaderUserAgent          = "user-agent"
+	HeaderReferer            = "referer"
+	HeaderXForwardedFor      = "x-forwarded-for"
+	HeaderXRequestID         = "x-request-id"
+	HeaderXEnvoyOriginalPath = "x-envoy-original-path"
+)
+
+var (
+	isAlwaysCapturedRequestHeader = func(header string) bool {
+		switch header {
+		case HeaderMethod,
+			HeaderScheme,
+			HeaderAuthority,
+			HeaderPath,
+			HeaderUserAgent,
+			HeaderReferer,
+			HeaderXForwardedFor,
+			HeaderXRequestID,
+			HeaderXEnvoyOriginalPath:
+			return true
+		default:
+			return false
+		}
+	}
+	isNotCapturedByDefaultRequestHeader = func(header string) bool {
+		return !isAlwaysCapturedRequestHeader(header)
+	}
+)
+
+// RequestHeaderOperator represents a `%REQ(X?Y):Z%` command operator.
+type RequestHeaderOperator struct {
+	HeaderFormatter
+}
+
+func (f *RequestHeaderOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.Format(&RequestHeaders{entry.GetRequest()})
+}
+
+func (f *RequestHeaderOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return "", nil
+}
+
+func (f *RequestHeaderOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	config.AdditionalRequestHeadersToLog = stringList(f.GetOperandHeaders()).
+		Filter(isNotCapturedByDefaultRequestHeader).
+		AppendToSet(config.AdditionalRequestHeadersToLog)
+	return nil
+}
+
+func (f *RequestHeaderOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f *RequestHeaderOperator) String() string {
+	return fmt.Sprintf("%%REQ%s%%", f.HeaderFormatter.String())
+}
+
+// RequestHeaders represents a set of HTTP request headers
+// that includes both regular headers, such as `referer` and `user-agent`,
+// and pseudo headers, such as `:method`, `:authority` and `:path`.
+type RequestHeaders struct {
+	*accesslog_data.HTTPRequestProperties
+}
+
+func (h *RequestHeaders) Get(name string) (string, bool) {
+	switch name {
+	case HeaderMethod:
+		return h.formatHttpMethod(h.GetRequestMethod())
+	case HeaderScheme:
+		return h.optionalString(h.GetScheme())
+	case HeaderAuthority:
+		return h.optionalString(h.GetAuthority())
+	case HeaderPath:
+		return h.optionalString(h.GetPath())
+	case HeaderUserAgent:
+		return h.optionalString(h.GetUserAgent())
+	case HeaderReferer:
+		return h.optionalString(h.GetReferer())
+	case HeaderXForwardedFor:
+		return h.optionalString(h.GetForwardedFor())
+	case HeaderXRequestID:
+		return h.optionalString(h.GetRequestId())
+	case HeaderXEnvoyOriginalPath:
+		return h.optionalString(h.GetOriginalPath())
+	default:
+		value, exists := h.GetRequestHeaders()[name]
+		return value, exists
+	}
+}
+
+func (h *RequestHeaders) formatHttpMethod(method envoy_core.RequestMethod) (string, bool) {
+	switch method {
+	case envoy_core.RequestMethod_METHOD_UNSPECIFIED:
+		return "", false
+	default:
+		return method.String(), true
+	}
+}
+
+func (h *RequestHeaders) optionalString(value string) (string, bool) {
+	return value, value != ""
+}
diff --git a/pkg/envoy/accesslog/v3/response_flags.go b/pkg/envoy/accesslog/v3/response_flags.go
new file mode 100644
index 000000000..d8097fec0
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/response_flags.go
@@ -0,0 +1,24 @@
+package v3
+
+// List of supported response flags.
+const (
+	ResponseFlagDownstreamConnectionTermination = "DC"
+	ResponseFlagFailedLocalHealthCheck          = "LH"
+	ResponseFlagNoHealthyUpstream               = "UH"
+	ResponseFlagUpstreamRequestTimeout          = "UT"
+	ResponseFlagLocalReset                      = "LR"
+	ResponseFlagUpstreamRemoteReset             = "UR"
+	ResponseFlagUpstreamConnectionFailure       = "UF"
+	ResponseFlagUpstreamConnectionTermination   = "UC"
+	ResponseFlagUpstreamOverflow                = "UO"
+	ResponseFlagUpstreamRetryLimitExceeded      = "URX"
+	ResponseFlagNoRouteFound                    = "NR"
+	ResponseFlagDelayInjected                   = "DI"
+	ResponseFlagFaultInjected                   = "FI"
+	ResponseFlagRateLimited                     = "RL"
+	ResponseFlagUnauthorizedExternalService     = "UAEX"
+	ResponseFlagRatelimitServiceError           = "RLSE"
+	ResponseFlagStreamIdleTimeout               = "SI"
+	ResponseFlagInvalidEnvoyRequestHeaders      = "IH"
+	ResponseFlagDownstreamProtocolError         = "DPE"
+)
diff --git a/pkg/envoy/accesslog/v3/response_header_operator.go b/pkg/envoy/accesslog/v3/response_header_operator.go
new file mode 100644
index 000000000..f8dc99dbc
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/response_header_operator.go
@@ -0,0 +1,36 @@
+package v3
+
+import (
+	"fmt"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// ResponseHeaderOperator represents a `%RESP(X?Y):Z%` command operator.
+type ResponseHeaderOperator struct {
+	HeaderFormatter
+}
+
+func (f *ResponseHeaderOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.Format(HeaderMap(entry.GetResponse().GetResponseHeaders()))
+}
+
+func (f *ResponseHeaderOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return "", nil
+}
+
+func (f *ResponseHeaderOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	config.AdditionalResponseHeadersToLog = stringList(f.GetOperandHeaders()).AppendToSet(config.AdditionalResponseHeadersToLog)
+	return nil
+}
+
+func (f *ResponseHeaderOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f *ResponseHeaderOperator) String() string {
+	return fmt.Sprintf("%%RESP%s%%", f.HeaderFormatter.String())
+}
diff --git a/pkg/envoy/accesslog/v3/response_trailer_operator.go b/pkg/envoy/accesslog/v3/response_trailer_operator.go
new file mode 100644
index 000000000..e1ab7749d
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/response_trailer_operator.go
@@ -0,0 +1,36 @@
+package v3
+
+import (
+	"fmt"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// ResponseTrailerOperator represents a `%TRAILER(X?Y):Z%` command operator.
+type ResponseTrailerOperator struct {
+	HeaderFormatter
+}
+
+func (f *ResponseTrailerOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.Format(HeaderMap(entry.GetResponse().GetResponseTrailers()))
+}
+
+func (f *ResponseTrailerOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return "", nil
+}
+
+func (f *ResponseTrailerOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	config.AdditionalResponseTrailersToLog = stringList(f.GetOperandHeaders()).AppendToSet(config.AdditionalResponseTrailersToLog)
+	return nil
+}
+
+func (f *ResponseTrailerOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f *ResponseTrailerOperator) String() string {
+	return fmt.Sprintf("%%TRAILER%s%%", f.HeaderFormatter.String())
+}
diff --git a/pkg/envoy/accesslog/v3/start_time_operator.go b/pkg/envoy/accesslog/v3/start_time_operator.go
new file mode 100644
index 000000000..50c675fb9
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/start_time_operator.go
@@ -0,0 +1,56 @@
+package v3
+
+import (
+	"fmt"
+
+	"github.com/golang/protobuf/ptypes"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+const (
+	// defaultStartTimeFormat is a Golang's equivalent of "%Y-%m-%dT%H:%M:%E3SZ" in C++.
+	defaultStartTimeFormat = "2006-01-02T15:04:05.000Z"
+)
+
+// StartTimeOperator represents a `%START_TIME%` command operator.
+type StartTimeOperator string
+
+func (f StartTimeOperator) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f StartTimeOperator) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return f.format(entry.GetCommonProperties())
+}
+
+func (f StartTimeOperator) format(entry *accesslog_data.AccessLogCommon) (string, error) {
+	if entry.GetStartTime() == nil {
+		return "", nil
+	}
+	startTime, err := ptypes.Timestamp(entry.GetStartTime())
+	if err != nil {
+		return "", err
+	}
+	// TODO(yskopets): take format string parameter into account
+	return startTime.Format(defaultStartTimeFormat), nil
+}
+
+func (f StartTimeOperator) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	// has no effect on HttpGrpcAccessLogConfig
+	return nil
+}
+
+func (f StartTimeOperator) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f StartTimeOperator) String() string {
+	if f == "" {
+		return CommandOperatorDescriptor(CMD_START_TIME).String()
+	}
+	return fmt.Sprintf("%%START_TIME(%s)%%", string(f))
+}
diff --git a/pkg/envoy/accesslog/v3/text_span_fragment.go b/pkg/envoy/accesslog/v3/text_span_fragment.go
new file mode 100644
index 000000000..7f6f1a4f0
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/text_span_fragment.go
@@ -0,0 +1,32 @@
+package v3
+
+import (
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+// TextSpan represents a span of plain text.
+type TextSpan string
+
+func (f TextSpan) FormatHttpLogEntry(entry *accesslog_data.HTTPAccessLogEntry) (string, error) {
+	return f.String(), nil
+}
+
+func (f TextSpan) FormatTcpLogEntry(entry *accesslog_data.TCPAccessLogEntry) (string, error) {
+	return f.String(), nil
+}
+
+func (f TextSpan) ConfigureHttpLog(config *accesslog_config.HttpGrpcAccessLogConfig) error {
+	// has no effect on HttpGrpcAccessLogConfig
+	return nil
+}
+
+func (f TextSpan) ConfigureTcpLog(config *accesslog_config.TcpGrpcAccessLogConfig) error {
+	// has no effect on TcpGrpcAccessLogConfig
+	return nil
+}
+
+// String returns the canonical representation of this access log fragment.
+func (f TextSpan) String() string {
+	return string(f)
+}
diff --git a/pkg/envoy/accesslog/v3/tls_cipher_suites.go b/pkg/envoy/accesslog/v3/tls_cipher_suites.go
new file mode 100644
index 000000000..945237d29
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/tls_cipher_suites.go
@@ -0,0 +1,109 @@
+package v3
+
+import (
+	"fmt"
+)
+
+// TlsCipherSuite represents a registered TLS cipher suite.
+type TlsCipherSuite uint16
+
+// List of the most commonly used TLS cipher suites.
+//
+// See https://www.iana.org/assignments/tls-parameters/tls-parameters.xml
+const (
+	// TLS 1.0 - 1.2 cipher suites.
+	TLS_RSA_WITH_RC4_128_SHA                TlsCipherSuite = 0x0005
+	TLS_RSA_WITH_3DES_EDE_CBC_SHA           TlsCipherSuite = 0x000a
+	TLS_RSA_WITH_AES_128_CBC_SHA            TlsCipherSuite = 0x002f
+	TLS_RSA_WITH_AES_256_CBC_SHA            TlsCipherSuite = 0x0035
+	TLS_RSA_WITH_AES_128_CBC_SHA256         TlsCipherSuite = 0x003c
+	TLS_RSA_WITH_AES_128_GCM_SHA256         TlsCipherSuite = 0x009c
+	TLS_RSA_WITH_AES_256_GCM_SHA384         TlsCipherSuite = 0x009d
+	TLS_ECDHE_ECDSA_WITH_RC4_128_SHA        TlsCipherSuite = 0xc007
+	TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA    TlsCipherSuite = 0xc009
+	TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA    TlsCipherSuite = 0xc00a
+	TLS_ECDHE_RSA_WITH_RC4_128_SHA          TlsCipherSuite = 0xc011
+	TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA     TlsCipherSuite = 0xc012
+	TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA      TlsCipherSuite = 0xc013
+	TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA      TlsCipherSuite = 0xc014
+	TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 TlsCipherSuite = 0xc023
+	TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256   TlsCipherSuite = 0xc027
+	TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256   TlsCipherSuite = 0xc02f
+	TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TlsCipherSuite = 0xc02b
+	TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384   TlsCipherSuite = 0xc030
+	TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TlsCipherSuite = 0xc02c
+	TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305    TlsCipherSuite = 0xcca8
+	TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305  TlsCipherSuite = 0xcca9
+
+	// TLS 1.3 cipher suites.
+	TLS_AES_128_GCM_SHA256       TlsCipherSuite = 0x1301
+	TLS_AES_256_GCM_SHA384       TlsCipherSuite = 0x1302
+	TLS_CHACHA20_POLY1305_SHA256 TlsCipherSuite = 0x1303
+
+	// TLS_FALLBACK_SCSV isn't a standard cipher suite but an indicator
+	// that the client is doing version fallback. See RFC 7507.
+	TLS_FALLBACK_SCSV TlsCipherSuite = 0x5600
+)
+
+// String returns an Envoy-compatible name of a TLS cipher suite.
+func (s TlsCipherSuite) String() string {
+	switch s {
+	// TLS 1.0 - 1.2 cipher suites.
+	case TLS_RSA_WITH_RC4_128_SHA:
+		return "RSA-RC4-128-SHA"
+	case TLS_RSA_WITH_3DES_EDE_CBC_SHA:
+		return "RSA-3DES-EDE-CBC-SHA"
+	case TLS_RSA_WITH_AES_128_CBC_SHA:
+		return "RSA-AES-128-CBC-SHA"
+	case TLS_RSA_WITH_AES_256_CBC_SHA:
+		return "RSA-AES-256-CBC-SHA"
+	case TLS_RSA_WITH_AES_128_CBC_SHA256:
+		return "RSA-AES-128-CBC-SHA256"
+	case TLS_RSA_WITH_AES_128_GCM_SHA256:
+		return "RSA-AES-128-GCM-SHA256"
+	case TLS_RSA_WITH_AES_256_GCM_SHA384:
+		return "RSA-AES-256-GCM-SHA384"
+	case TLS_ECDHE_ECDSA_WITH_RC4_128_SHA:
+		return "ECDHE-ECDSA-RC4-128-SHA"
+	case TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA:
+		return "ECDHE-ECDSA-AES-128-CBC-SHA"
+	case TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA:
+		return "ECDHE-ECDSA-AES-256-CBC-SHA"
+	case TLS_ECDHE_RSA_WITH_RC4_128_SHA:
+		return "ECDHE-RSA-RC4-128-SHA"
+	case TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA:
+		return "ECDHE-RSA-3DES-EDE-CBC-SHA"
+	case TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:
+		return "ECDHE-RSA-AES-128-CBC-SHA"
+	case TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:
+		return "ECDHE-RSA-AES-256-CBC-SHA"
+	case TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256:
+		return "ECDHE-ECDSA-AES-128-CBC-SHA256"
+	case TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256:
+		return "ECDHE-RSA-AES-128-CBC-SHA256"
+	case TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:
+		return "ECDHE-RSA-AES-128-GCM-SHA256"
+	case TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:
+		return "ECDHE-ECDSA-AES-128-GCM-SHA256"
+	case TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:
+		return "ECDHE-RSA-AES-256-GCM-SHA384"
+	case TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:
+		return "ECDHE-ECDSA-AES-256-GCM-SHA384"
+	case TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305:
+		return "ECDHE-RSA-CHACHA20-POLY1305"
+	case TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305:
+		return "ECDHE-ECDSA-CHACHA20-POLY1305"
+	// TLS 1.3 cipher suites.
+	case TLS_AES_128_GCM_SHA256:
+		return "AES-128-GCM-SHA256"
+	case TLS_AES_256_GCM_SHA384:
+		return "AES-256-GCM-SHA384"
+	case TLS_CHACHA20_POLY1305_SHA256:
+		return "CHACHA20-POLY1305-SHA256"
+	// RFC 7507
+	case TLS_FALLBACK_SCSV:
+		return "FALLBACK-SCSV"
+	default:
+		return fmt.Sprintf("%#x", uint16(s))
+	}
+}
diff --git a/pkg/envoy/accesslog/v3/util.go b/pkg/envoy/accesslog/v3/util.go
new file mode 100644
index 000000000..daeddeec5
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/util.go
@@ -0,0 +1,31 @@
+package v3
+
+// stringList represents a list of strings.
+type stringList []string
+
+func (s stringList) Contains(given string) bool {
+	for _, value := range s {
+		if value == given {
+			return true
+		}
+	}
+	return false
+}
+
+func (s stringList) Filter(predicate func(string) bool) (filtered stringList) {
+	for _, value := range s {
+		if predicate(value) {
+			filtered = append(filtered, value)
+		}
+	}
+	return
+}
+
+func (s stringList) AppendToSet(dest []string) []string {
+	for _, value := range s {
+		if !stringList(dest).Contains(value) {
+			dest = append(dest, value)
+		}
+	}
+	return dest
+}
diff --git a/pkg/kds/cache/snapshot.go b/pkg/kds/cache/snapshot.go
index 2a2182446..275575207 100644
--- a/pkg/kds/cache/snapshot.go
+++ b/pkg/kds/cache/snapshot.go
@@ -21,11 +21,18 @@ type SnapshotBuilder interface {
 }
 
 type builder struct {
-	resources map[string][]envoy_types.Resource
+	resources map[string][]envoy_types.ResourceWithTtl
 }
 
 func (b *builder) With(typ string, resources []envoy_types.Resource) SnapshotBuilder {
-	b.resources[typ] = resources
+	ttlResources := make([]envoy_types.ResourceWithTtl, len(resources))
+	for i, res := range resources {
+		ttlResources[i] = envoy_types.ResourceWithTtl{
+			Resource: res,
+			Ttl:      nil,
+		}
+	}
+	b.resources[typ] = ttlResources
 	return b
 }
 
@@ -41,7 +48,7 @@ func (b *builder) Build(version string) util_xds.Snapshot {
 }
 
 func NewSnapshotBuilder() SnapshotBuilder {
-	return &builder{resources: map[string][]envoy_types.Resource{}}
+	return &builder{resources: map[string][]envoy_types.ResourceWithTtl{}}
 }
 
 // Snapshot is an internally consistent snapshot of xDS resources.
@@ -69,6 +76,23 @@ func (s *Snapshot) GetResources(typ string) map[string]envoy_types.Resource {
 	if s == nil {
 		return nil
 	}
+
+	resources := s.GetResourcesAndTtl(typ)
+	if resources == nil {
+		return nil
+	}
+
+	withoutTtl := make(map[string]envoy_types.Resource, len(resources))
+	for name, res := range resources {
+		withoutTtl[name] = res.Resource
+	}
+	return withoutTtl
+}
+
+func (s *Snapshot) GetResourcesAndTtl(typ string) map[string]envoy_types.ResourceWithTtl {
+	if s == nil {
+		return nil
+	}
 	if r, ok := s.Resources[typ]; ok {
 		return r.Items
 	}
@@ -107,10 +131,10 @@ func (s *Snapshot) WithVersion(typ string, version string) util_xds.Snapshot {
 
 // IndexResourcesByName creates a map from the resource name to the resource. Name should be unique
 // across meshes that's why Name is <name>.<mesh>
-func IndexResourcesByName(items []envoy_types.Resource) map[string]envoy_types.Resource {
-	indexed := make(map[string]envoy_types.Resource, len(items))
+func IndexResourcesByName(items []envoy_types.ResourceWithTtl) map[string]envoy_types.ResourceWithTtl {
+	indexed := make(map[string]envoy_types.ResourceWithTtl, len(items))
 	for _, item := range items {
-		key := fmt.Sprintf("%s.%s", item.(*mesh_proto.KumaResource).GetMeta().GetName(), item.(*mesh_proto.KumaResource).GetMeta().GetMesh())
+		key := fmt.Sprintf("%s.%s", item.Resource.(*mesh_proto.KumaResource).GetMeta().GetName(), item.Resource.(*mesh_proto.KumaResource).GetMeta().GetMesh())
 		indexed[key] = item
 	}
 	return indexed
diff --git a/pkg/mads/cache/snapshot.go b/pkg/mads/cache/snapshot.go
index 62f926d01..e03e32a26 100644
--- a/pkg/mads/cache/snapshot.go
+++ b/pkg/mads/cache/snapshot.go
@@ -12,8 +12,14 @@ import (
 
 // NewSnapshot creates a snapshot from response types and a version.
 func NewSnapshot(version string, assignments map[string]envoy_types.Resource) *Snapshot {
+	withTtl := make(map[string]envoy_types.ResourceWithTtl, len(assignments))
+	for name, res := range assignments {
+		withTtl[name] = envoy_types.ResourceWithTtl{
+			Resource: res,
+		}
+	}
 	return &Snapshot{
-		MonitoringAssignments: envoy_cache.Resources{Version: version, Items: assignments},
+		MonitoringAssignments: envoy_cache.Resources{Version: version, Items: withTtl},
 	}
 }
 
@@ -43,6 +49,23 @@ func (s *Snapshot) GetResources(typ string) map[string]envoy_types.Resource {
 	if s == nil {
 		return nil
 	}
+
+	resources := s.GetResourcesAndTtl(typ)
+	if resources == nil {
+		return nil
+	}
+
+	withoutTtl := make(map[string]envoy_types.Resource, len(resources))
+	for name, res := range resources {
+		withoutTtl[name] = res.Resource
+	}
+	return withoutTtl
+}
+
+func (s *Snapshot) GetResourcesAndTtl(typ string) map[string]envoy_types.ResourceWithTtl {
+	if s == nil {
+		return nil
+	}
 	switch typ {
 	case mads.MonitoringAssignmentType:
 		return s.MonitoringAssignments.Items
diff --git a/pkg/sds/server/v2/reconciler.go b/pkg/sds/server/v2/reconciler.go
index b2f89a431..237df270a 100644
--- a/pkg/sds/server/v2/reconciler.go
+++ b/pkg/sds/server/v2/reconciler.go
@@ -161,7 +161,7 @@ func (d *DataplaneReconciler) generateSnapshot(dataplane *mesh_core.DataplaneRes
 }
 
 func (d *DataplaneReconciler) updateInsights(dataplaneId core_model.ResourceKey, snapshot envoy_cache.Snapshot) error {
-	secret := snapshot.Resources[envoy_types.Secret].Items[tls.IdentityCertResource].(*envoy_auth.Secret)
+	secret := snapshot.Resources[envoy_types.Secret].Items[tls.IdentityCertResource].Resource.(*envoy_auth.Secret)
 	certPEM := secret.GetTlsCertificate().CertificateChain.GetInlineBytes()
 	block, _ := pem.Decode(certPEM)
 	cert, err := x509.ParseCertificate(block.Bytes)
diff --git a/pkg/sds/server/v3/reconciler.go b/pkg/sds/server/v3/reconciler.go
index e68743ae4..b528d7918 100644
--- a/pkg/sds/server/v3/reconciler.go
+++ b/pkg/sds/server/v3/reconciler.go
@@ -161,7 +161,7 @@ func (d *DataplaneReconciler) generateSnapshot(dataplane *mesh_core.DataplaneRes
 }
 
 func (d *DataplaneReconciler) updateInsights(dataplaneId core_model.ResourceKey, snapshot envoy_cache.Snapshot) error {
-	secret := snapshot.Resources[envoy_types.Secret].Items[tls.IdentityCertResource].(*envoy_auth.Secret)
+	secret := snapshot.Resources[envoy_types.Secret].Items[tls.IdentityCertResource].Resource.(*envoy_auth.Secret)
 	certPEM := secret.GetTlsCertificate().CertificateChain.GetInlineBytes()
 	block, _ := pem.Decode(certPEM)
 	cert, err := x509.ParseCertificate(block.Bytes)
diff --git a/pkg/util/cache/v2/cache.go b/pkg/util/cache/v2/cache.go
index 438ba4982..ecf6d9765 100644
--- a/pkg/util/cache/v2/cache.go
+++ b/pkg/util/cache/v2/cache.go
@@ -14,7 +14,7 @@ func ToDeltaDiscoveryResponse(s ctl_cache.Snapshot) (*v2.DeltaDiscoveryResponse,
 	for _, rs := range s.Resources {
 		for _, name := range sortedResourceNames(rs) {
 			r := rs.Items[name]
-			pbany, err := ptypes.MarshalAny(r)
+			pbany, err := ptypes.MarshalAny(r.Resource)
 			if err != nil {
 				return nil, err
 			}
diff --git a/pkg/util/cache/v3/cache.go b/pkg/util/cache/v3/cache.go
index cd0bf3873..c43bf4652 100644
--- a/pkg/util/cache/v3/cache.go
+++ b/pkg/util/cache/v3/cache.go
@@ -14,7 +14,7 @@ func ToDeltaDiscoveryResponse(s ctl_cache.Snapshot) (*v3.DeltaDiscoveryResponse,
 	for _, rs := range s.Resources {
 		for _, name := range sortedResourceNames(rs) {
 			r := rs.Items[name]
-			pbany, err := ptypes.MarshalAny(r)
+			pbany, err := ptypes.MarshalAny(r.Resource)
 			if err != nil {
 				return nil, err
 			}
diff --git a/pkg/util/xds/cache.go b/pkg/util/xds/cache.go
index 7fbf4ee36..9a66b98aa 100644
--- a/pkg/util/xds/cache.go
+++ b/pkg/util/xds/cache.go
@@ -57,6 +57,9 @@ type Snapshot interface {
 	// GetResources selects snapshot resources by type.
 	GetResources(typ string) map[string]envoy_types.Resource
 
+	// GetResourcesAndTtl selects snapshot resources by type, returning the map of resources and the associated TTL.
+	GetResourcesAndTtl(typ string) map[string]envoy_types.ResourceWithTtl
+
 	// GetVersion returns the version for a resource type.
 	GetVersion(typ string) string
 
@@ -99,8 +102,6 @@ type SnapshotCache interface {
 	GetStatusKeys() []string
 }
 
-// The part below is a exact copy of snapshotCache and statusInfo of go-control-plane with exception marked with `Kuma modification start`
-
 type snapshotCache struct {
 	// watchCount is an atomic counter incremented for each watch. This needs to
 	// be the first field in the struct to guarantee that it is 64-bit aligned,
@@ -137,13 +138,89 @@ type snapshotCache struct {
 //
 // Logger is optional.
 func NewSnapshotCache(ads bool, hash envoy_cache.NodeHash, logger envoy_log.Logger) SnapshotCache {
-	return &snapshotCache{
+	return newSnapshotCache(ads, hash, logger)
+}
+
+func newSnapshotCache(ads bool, hash envoy_cache.NodeHash, logger envoy_log.Logger) *snapshotCache {
+	cache := &snapshotCache{
 		log:       logger,
 		ads:       ads,
 		snapshots: make(map[string]Snapshot),
 		status:    make(map[string]*statusInfo),
 		hash:      hash,
 	}
+
+	return cache
+}
+
+// NewSnapshotCacheWithHeartbeating initializes a simple cache that sends periodic heartbeat
+// responses for resources with a TTL.
+//
+// ADS flag forces a delay in responding to streaming requests until all
+// resources are explicitly named in the request. This avoids the problem of a
+// partial request over a single stream for a subset of resources which would
+// require generating a fresh version for acknowledgement. ADS flag requires
+// snapshot consistency. For non-ADS case (and fetch), multiple partial
+// requests are sent across multiple streams and re-using the snapshot version
+// is OK.
+//
+// Logger is optional.
+//
+// The context provides a way to cancel the heartbeating routine, while the heartbeatInterval
+// parameter controls how often heartbeating occurs.
+func NewSnapshotCacheWithHeartbeating(ctx context.Context, ads bool, hash envoy_cache.NodeHash, logger envoy_log.Logger, heartbeatInterval time.Duration) SnapshotCache {
+	cache := newSnapshotCache(ads, hash, logger)
+	go func() {
+		t := time.NewTicker(heartbeatInterval)
+
+		for {
+			select {
+			case <-t.C:
+				cache.mu.Lock()
+				for node := range cache.status {
+					// TODO(snowp): Omit heartbeats if a real response has been sent recently.
+					cache.sendHeartbeats(ctx, node)
+				}
+				cache.mu.Unlock()
+			case <-ctx.Done():
+				return
+			}
+		}
+	}()
+	return cache
+}
+
+func (cache *snapshotCache) sendHeartbeats(ctx context.Context, node string) {
+	snapshot := cache.snapshots[node]
+	if info, ok := cache.status[node]; ok {
+		info.mu.Lock()
+		for id, watch := range info.watches {
+			// Respond with the current version regardless of whether the version has changed.
+			version := snapshot.GetVersion(watch.Request.TypeUrl)
+			resources := snapshot.GetResourcesAndTtl(watch.Request.TypeUrl)
+
+			// TODO(snowp): Construct this once per type instead of once per watch.
+			resourcesWithTtl := map[string]envoy_types.ResourceWithTtl{}
+			for k, v := range resources {
+				if v.Ttl != nil {
+					resourcesWithTtl[k] = v
+				}
+			}
+
+			if len(resourcesWithTtl) == 0 {
+				continue
+			}
+			if cache.log != nil {
+				cache.log.Debugf("respond open watch %d%v with heartbeat for version %q", id, watch.Request.ResourceNames, version)
+			}
+
+			cache.respond(watch.Request, watch.Response, resourcesWithTtl, version, true)
+
+			// The watch must be deleted and we must rely on the client to ack this response to create a new watch.
+			delete(info.watches, id)
+		}
+		info.mu.Unlock()
+	}
 }
 
 // SetSnapshotCache updates a snapshot for a node.
@@ -163,7 +240,8 @@ func (cache *snapshotCache) SetSnapshot(node string, snapshot Snapshot) error {
 				if cache.log != nil {
 					cache.log.Debugf("respond open watch %d%v with new version %q", id, watch.Request.ResourceNames, version)
 				}
-				cache.respond(watch.Request, watch.Response, snapshot.GetResources(watch.Request.TypeUrl), version)
+				resources := snapshot.GetResourcesAndTtl(watch.Request.TypeUrl)
+				cache.respond(watch.Request, watch.Response, resources, version, false)
 
 				// discard the watch
 				delete(info.watches, id)
@@ -206,7 +284,7 @@ func nameSet(names []string) map[string]bool {
 }
 
 // superset checks that all resources are listed in the names set.
-func superset(names map[string]bool, resources map[string]envoy_types.Resource) error {
+func superset(names map[string]bool, resources map[string]envoy_types.ResourceWithTtl) error {
 	for resourceName := range resources {
 		if _, exists := names[resourceName]; !exists {
 			return fmt.Errorf("%q not listed", resourceName)
@@ -258,7 +336,8 @@ func (cache *snapshotCache) CreateWatch(request *envoy_cache.Request) (chan envo
 	}
 
 	// otherwise, the watch may be responded immediately
-	cache.respond(request, value, snapshot.GetResources(request.TypeUrl), version)
+	resources := snapshot.GetResourcesAndTtl(request.TypeUrl)
+	cache.respond(request, value, resources, version, false)
 
 	return value, nil
 }
@@ -283,7 +362,7 @@ func (cache *snapshotCache) cancelWatch(nodeID string, watchID int64) func() {
 
 // Respond to a watch with the snapshot value. The value channel should have capacity not to block.
 // TODO(kuat) do not respond always, see issue https://github.com/envoyproxy/go-control-plane/issues/46
-func (cache *snapshotCache) respond(request *envoy_cache.Request, value chan envoy_cache.Response, resources map[string]envoy_types.Resource, version string) {
+func (cache *snapshotCache) respond(request *envoy_cache.Request, value chan envoy_cache.Response, resources map[string]envoy_types.ResourceWithTtl, version string, heartbeat bool) {
 	// for ADS, the request names must match the snapshot names
 	// if they do not, then the watch is never responded, and it is expected that envoy makes another request
 	if len(request.ResourceNames) != 0 && cache.ads {
@@ -299,11 +378,11 @@ func (cache *snapshotCache) respond(request *envoy_cache.Request, value chan env
 			request.TypeUrl, request.ResourceNames, request.VersionInfo, version)
 	}
 
-	value <- cache.createResponse(request, resources, version)
+	value <- createResponse(request, resources, version, heartbeat)
 }
 
-func (cache *snapshotCache) createResponse(request *envoy_cache.Request, resources map[string]envoy_types.Resource, version string) envoy_cache.Response {
-	filtered := make([]envoy_types.Resource, 0, len(resources))
+func createResponse(request *envoy_cache.Request, resources map[string]envoy_types.ResourceWithTtl, version string, heartbeat bool) envoy_cache.Response {
+	filtered := make([]envoy_types.ResourceWithTtl, 0, len(resources))
 
 	// Reply only with the requested resources. Envoy may ask each resource
 	// individually in a separate stream. It is ok to reply with the same version
@@ -325,6 +404,7 @@ func (cache *snapshotCache) createResponse(request *envoy_cache.Request, resourc
 		Request:   request,
 		Version:   version,
 		Resources: filtered,
+		Heartbeat: heartbeat,
 	}
 }
 
@@ -347,8 +427,8 @@ func (cache *snapshotCache) Fetch(ctx context.Context, request *envoy_cache.Requ
 			return nil, &envoy_types.SkipFetchError{}
 		}
 
-		resources := snapshot.GetResources(request.TypeUrl)
-		out := cache.createResponse(request, resources, version)
+		resources := snapshot.GetResourcesAndTtl(request.TypeUrl)
+		out := createResponse(request, resources, version, false)
 		return out, nil
 	}
 
diff --git a/pkg/xds/envoy/listeners/v2/access_log_configurer.go b/pkg/xds/envoy/listeners/v2/access_log_configurer.go
index ba2d95ab9..e83f7b3ba 100644
--- a/pkg/xds/envoy/listeners/v2/access_log_configurer.go
+++ b/pkg/xds/envoy/listeners/v2/access_log_configurer.go
@@ -13,7 +13,7 @@ import (
 
 	mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1"
 	core_xds "github.com/kumahq/kuma/pkg/core/xds"
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 	"github.com/kumahq/kuma/pkg/util/proto"
 	"github.com/kumahq/kuma/pkg/xds/envoy"
 )
diff --git a/pkg/xds/envoy/listeners/v3/access_log_configurer.go b/pkg/xds/envoy/listeners/v3/access_log_configurer.go
index c3579de20..92e7b95de 100644
--- a/pkg/xds/envoy/listeners/v3/access_log_configurer.go
+++ b/pkg/xds/envoy/listeners/v3/access_log_configurer.go
@@ -14,7 +14,7 @@ import (
 
 	mesh_proto "github.com/kumahq/kuma/api/mesh/v1alpha1"
 	core_xds "github.com/kumahq/kuma/pkg/core/xds"
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
 	"github.com/kumahq/kuma/pkg/util/proto"
 	"github.com/kumahq/kuma/pkg/xds/envoy"
 )
@@ -86,9 +86,9 @@ func tcpAccessLog(format *accesslog.AccessLogFormat, cfgStr *structpb.Struct) (*
 			},
 		},
 	}
-	// if err := format.ConfigureHttpLog(httpGrpcAccessLog); err != nil {
-	// 	return nil, errors.Wrapf(err, "failed to configure %T according to the format string: %s", httpGrpcAccessLog, format)
-	// } // todo
+	if err := format.ConfigureHttpLog(httpGrpcAccessLog); err != nil {
+		return nil, errors.Wrapf(err, "failed to configure %T according to the format string: %s", httpGrpcAccessLog, format)
+	}
 	marshalled, err := proto.MarshalAnyDeterministic(httpGrpcAccessLog)
 	if err != nil {
 		return nil, errors.Wrapf(err, "could not marshall %T", httpGrpcAccessLog)
diff --git a/pkg/xds/envoy/listeners/v3/original_dst_forwarder_configurer.go b/pkg/xds/envoy/listeners/v3/original_dst_forwarder_configurer.go
index 9c34b51d8..11c2898a8 100644
--- a/pkg/xds/envoy/listeners/v3/original_dst_forwarder_configurer.go
+++ b/pkg/xds/envoy/listeners/v3/original_dst_forwarder_configurer.go
@@ -2,6 +2,7 @@ package v3
 
 import (
 	envoy_listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
+	"github.com/golang/protobuf/ptypes/wrappers"
 )
 
 type OriginalDstForwarderConfigurer struct {
@@ -11,7 +12,7 @@ var _ ListenerConfigurer = &OriginalDstForwarderConfigurer{}
 
 func (c *OriginalDstForwarderConfigurer) Configure(l *envoy_listener.Listener) error {
 	// TODO(yskopets): What is the up-to-date alternative ?
-	// l.UseOriginalDst = &wrappers.BoolValue{Value: true}
+	l.UseOriginalDst = &wrappers.BoolValue{Value: true}
 
 	return nil
 }
diff --git a/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer.go b/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer.go
index ee5b734b8..a550712d4 100644
--- a/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer.go
+++ b/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer.go
@@ -10,10 +10,6 @@ type TransparentProxyingConfigurer struct {
 }
 
 func (c *TransparentProxyingConfigurer) Configure(l *envoy_listener.Listener) error {
-	// TODO(yskopets): What is the up-to-date alternative ?
-	l.DeprecatedV1 = &envoy_listener.Listener_DeprecatedV1{
-		BindToPort: &wrappers.BoolValue{Value: false},
-	}
-
+	l.BindToPort = &wrappers.BoolValue{Value: false}
 	return nil
 }
diff --git a/pkg/xds/server/v2/reconcile.go b/pkg/xds/server/v2/reconcile.go
index a5cdbfdf4..829155195 100644
--- a/pkg/xds/server/v2/reconcile.go
+++ b/pkg/xds/server/v2/reconcile.go
@@ -75,12 +75,12 @@ func reuseVersion(old, new envoy_cache.Resources) envoy_cache.Resources {
 	return new
 }
 
-func equalSnapshots(old, new map[string]envoy_types.Resource) bool {
+func equalSnapshots(old, new map[string]envoy_types.ResourceWithTtl) bool {
 	if len(new) != len(old) {
 		return false
 	}
 	for key, newValue := range new {
-		if oldValue, hasOldValue := old[key]; !hasOldValue || !proto.Equal(newValue, oldValue) {
+		if oldValue, hasOldValue := old[key]; !hasOldValue || !proto.Equal(newValue.Resource, oldValue.Resource) {
 			return false
 		}
 	}
diff --git a/pkg/xds/server/v3/reconcile.go b/pkg/xds/server/v3/reconcile.go
index f68ac78b0..47cb5c33b 100644
--- a/pkg/xds/server/v3/reconcile.go
+++ b/pkg/xds/server/v3/reconcile.go
@@ -75,12 +75,12 @@ func reuseVersion(old, new envoy_cache.Resources) envoy_cache.Resources {
 	return new
 }
 
-func equalSnapshots(old, new map[string]envoy_types.Resource) bool {
+func equalSnapshots(old, new map[string]envoy_types.ResourceWithTtl) bool {
 	if len(new) != len(old) {
 		return false
 	}
 	for key, newValue := range new {
-		if oldValue, hasOldValue := old[key]; !hasOldValue || !proto.Equal(newValue, oldValue) {
+		if oldValue, hasOldValue := old[key]; !hasOldValue || !proto.Equal(newValue.Resource, oldValue.Resource) {
 			return false
 		}
 	}


Test patch:
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/accesslogs_suite_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/accesslogs_suite_test.go
similarity index 88%
rename from app/kuma-dp/pkg/dataplane/accesslogs/accesslogs_suite_test.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/accesslogs_suite_test.go
index 0af397221..2e954dd96 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/accesslogs_suite_test.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/accesslogs_suite_test.go
@@ -1,4 +1,4 @@
-package accesslogs_test
+package v2_test
 
 import (
 	"testing"
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/factories_private_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/factories_private_test.go
similarity index 98%
rename from app/kuma-dp/pkg/dataplane/accesslogs/factories_private_test.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/factories_private_test.go
index ca319f70c..31b8fd722 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/factories_private_test.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/factories_private_test.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 import (
 	. "github.com/onsi/ginkgo"
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/handler_private_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/handler_private_test.go
similarity index 98%
rename from app/kuma-dp/pkg/dataplane/accesslogs/handler_private_test.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/handler_private_test.go
index 895c7f86b..d5ab3d6a9 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/handler_private_test.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/handler_private_test.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 import (
 	. "github.com/onsi/ginkgo"
@@ -7,7 +7,7 @@ import (
 
 	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v2"
 
-	"github.com/kumahq/kuma/pkg/envoy/accesslog"
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	util_proto "github.com/kumahq/kuma/pkg/util/proto"
 )
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/helper_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v2/helper_test.go
similarity index 92%
rename from app/kuma-dp/pkg/dataplane/accesslogs/helper_test.go
rename to app/kuma-dp/pkg/dataplane/accesslogs/v2/helper_test.go
index 95a367476..16e845a6f 100644
--- a/app/kuma-dp/pkg/dataplane/accesslogs/helper_test.go
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v2/helper_test.go
@@ -1,4 +1,4 @@
-package accesslogs
+package v2
 
 type fakeSender []string
 
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/accesslogs_suite_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/accesslogs_suite_test.go
new file mode 100644
index 000000000..4feac8e30
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/accesslogs_suite_test.go
@@ -0,0 +1,13 @@
+package v3_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestAccesslogs(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Accesslogs Suite")
+}
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories_private_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories_private_test.go
new file mode 100644
index 000000000..b11e4af45
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/factories_private_test.go
@@ -0,0 +1,40 @@
+package v3
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+)
+
+var _ = Describe("defaultHandler", func() {
+	Describe("error path", func() {
+		type testCase struct {
+			msg         *envoy_accesslog.StreamAccessLogsMessage
+			expectedErr string
+		}
+
+		DescribeTable("should fail if configuration is not valid",
+			func(given testCase) {
+				// when
+				_, err := defaultHandler(nil, given.msg)
+				// then
+				Expect(err).To(HaveOccurred())
+				// and
+				Expect(err.Error()).To(Equal(given.expectedErr))
+			},
+			Entry("empty `identifier.log_name` field", testCase{
+				expectedErr: `log name "" has invalid format: expected 2 components separated by ';', got 1`,
+			}),
+			Entry("invalid access log format string", testCase{
+				msg: &envoy_accesslog.StreamAccessLogsMessage{
+					Identifier: &envoy_accesslog.StreamAccessLogsMessage_Identifier{
+						LogName: ";%bytes_sent%",
+					},
+				},
+				expectedErr: `format string is not valid: expected a command operator to start at position 1, instead got: "%bytes_sent%"`,
+			}),
+		)
+	})
+})
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler_private_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler_private_test.go
new file mode 100644
index 000000000..c686f13d9
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/handler_private_test.go
@@ -0,0 +1,207 @@
+package v3
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	envoy_accesslog "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
+
+	accesslog "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	util_proto "github.com/kumahq/kuma/pkg/util/proto"
+)
+
+var _ = Describe("handler", func() {
+
+	Describe("Handle()", func() {
+		Describe("happy path", func() {
+
+			sampleFormat := `[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(CONTENT-TYPE)%
+` // intentional newline at the end
+
+			type testCase struct {
+				format   string
+				msg      string
+				expected []string
+			}
+
+			DescribeTable("should handle valid messages",
+				func(given testCase) {
+					By("doing setup")
+					fakeSender := fakeSender{}
+					// when
+					format, err := accesslog.ParseFormat(given.format)
+					// then
+					Expect(err).ToNot(HaveOccurred())
+					// and
+					handler := &handler{format: format, sender: &fakeSender}
+
+					// when
+					msg := &envoy_accesslog.StreamAccessLogsMessage{}
+					// and
+					err = util_proto.FromYAML([]byte(given.msg), msg)
+					// then
+					Expect(err).ToNot(HaveOccurred())
+
+					By("doing test")
+					// when
+					err = handler.Handle(msg)
+					// then
+					Expect(err).ToNot(HaveOccurred())
+					// and
+					Expect([]string(fakeSender)).To(Equal(given.expected))
+				},
+				Entry("empty HTTP log entry", testCase{
+					format: sampleFormat,
+					msg: `
+                    http_logs:
+                      log_entry:
+                      - {}
+`,
+					expected: []string{"[-] \"- - -\" 0 - 0 0 - -\n"},
+				}),
+				Entry("1 HTTP log entry", testCase{
+					format: sampleFormat,
+					msg: `
+                    http_logs:
+                      log_entry:
+                      - common_properties:
+                          start_time: 2020-02-11T12:34:56.123Z
+                        protocol_version: HTTP11
+                        request:
+                          request_method: POST
+                          authority: backend.internal:8080
+                          path: /api
+                          request_body_bytes: 234
+                        response:
+                          response_code: 200
+                          response_headers:
+                            content-type: application/json
+                          response_body_bytes: 567
+`,
+					expected: []string{"[2020-02-11T12:34:56.123Z] \"POST /api HTTP/1.1\" 200 - 234 567 - application/json\n"},
+				}),
+				Entry("2 HTTP log entries", testCase{
+					format: sampleFormat,
+					msg: `
+                    http_logs:
+                      log_entry:
+                      - common_properties:
+                          start_time: 2020-02-11T12:34:56.123Z
+                        protocol_version: HTTP11
+                        request:
+                          request_method: POST
+                          authority: backend.internal:8080
+                          path: /api
+                          request_body_bytes: 234
+                        response:
+                          response_code: 200
+                          response_headers:
+                            content-type: application/json
+                          response_body_bytes: 567
+                      - common_properties:
+                          start_time: 2020-02-18T23:45:07.456Z
+                        protocol_version: HTTP2
+                        request:
+                          request_method: GET
+                          authority: web.internal:8080
+                          path: /index.html
+                          request_body_bytes: 0
+                        response:
+                          response_code: 301
+                          response_headers:
+                            content-type: text/html
+                          response_body_bytes: 89012
+`,
+					expected: []string{
+						"[2020-02-11T12:34:56.123Z] \"POST /api HTTP/1.1\" 200 - 234 567 - application/json\n",
+						"[2020-02-18T23:45:07.456Z] \"GET /index.html HTTP/2\" 301 - 0 89012 - text/html\n",
+					},
+				}),
+				Entry("empty TCP log entry", testCase{
+					format: sampleFormat,
+					msg: `
+                    tcp_logs:
+                      log_entry:
+                      - {}
+`,
+					expected: []string{"[-] \"- - -\" 0 - 0 0 - -\n"},
+				}),
+				Entry("1 HTTP log entry", testCase{
+					format: sampleFormat,
+					msg: `
+                    tcp_logs:
+                      log_entry:
+                      - common_properties:
+                          start_time: 2020-02-11T12:34:56.123Z
+                        connection_properties:
+                          received_bytes: 234
+                          sent_bytes: 567
+`,
+					expected: []string{"[2020-02-11T12:34:56.123Z] \"- - -\" 0 - 234 567 - -\n"},
+				}),
+				Entry("2 TCP log entries", testCase{
+					format: sampleFormat,
+					msg: `
+                    tcp_logs:
+                      log_entry:
+                      - common_properties:
+                          start_time: 2020-02-11T12:34:56.123Z
+                        connection_properties:
+                          received_bytes: 234
+                          sent_bytes: 567
+                      - common_properties:
+                          start_time: 2020-02-18T23:45:07.456Z
+                        connection_properties:
+                          received_bytes: 0
+                          sent_bytes: 89012
+`,
+					expected: []string{
+						"[2020-02-11T12:34:56.123Z] \"- - -\" 0 - 234 567 - -\n",
+						"[2020-02-18T23:45:07.456Z] \"- - -\" 0 - 0 89012 - -\n",
+					},
+				}),
+			)
+		})
+
+		Describe("error path", func() {
+			type testCase struct {
+				format      string
+				msg         string
+				expectedErr string
+			}
+
+			DescribeTable("should reject invalid mesasages",
+				func(given testCase) {
+					By("doing setup")
+					fakeSender := fakeSender{}
+					// when
+					format, err := accesslog.ParseFormat(given.format)
+					// then
+					Expect(err).ToNot(HaveOccurred())
+					// and
+					handler := &handler{format: format, sender: &fakeSender}
+
+					// when
+					msg := &envoy_accesslog.StreamAccessLogsMessage{}
+					// and
+					err = util_proto.FromYAML([]byte(given.msg), msg)
+					// then
+					Expect(err).ToNot(HaveOccurred())
+
+					By("doing test")
+					// when
+					err = handler.Handle(msg)
+					// then
+					Expect(err).To(HaveOccurred())
+					// and
+					Expect(err.Error()).To(Equal(given.expectedErr))
+				},
+				Entry("no log entries", testCase{
+					expectedErr: `unknown type of log entries: <nil>`,
+				}),
+			)
+		})
+	})
+})
diff --git a/app/kuma-dp/pkg/dataplane/accesslogs/v3/helper_test.go b/app/kuma-dp/pkg/dataplane/accesslogs/v3/helper_test.go
new file mode 100644
index 000000000..2c0973fb9
--- /dev/null
+++ b/app/kuma-dp/pkg/dataplane/accesslogs/v3/helper_test.go
@@ -0,0 +1,14 @@
+package v3
+
+type fakeSender []string
+
+func (s *fakeSender) Connect() error {
+	return nil
+}
+func (s *fakeSender) Send(record string) error {
+	*s = append(*s, record)
+	return nil
+}
+func (s *fakeSender) Close() error {
+	return nil
+}
diff --git a/pkg/envoy/accesslog/accesslog_suite_test.go b/pkg/envoy/accesslog/v2/accesslog_suite_test.go
similarity index 88%
rename from pkg/envoy/accesslog/accesslog_suite_test.go
rename to pkg/envoy/accesslog/v2/accesslog_suite_test.go
index 419300653..d545e2787 100644
--- a/pkg/envoy/accesslog/accesslog_suite_test.go
+++ b/pkg/envoy/accesslog/v2/accesslog_suite_test.go
@@ -1,4 +1,4 @@
-package accesslog_test
+package v2_test
 
 import (
 	"testing"
diff --git a/pkg/envoy/accesslog/dynamic_metadata_operator_test.go b/pkg/envoy/accesslog/v2/dynamic_metadata_operator_test.go
similarity index 97%
rename from pkg/envoy/accesslog/dynamic_metadata_operator_test.go
rename to pkg/envoy/accesslog/v2/dynamic_metadata_operator_test.go
index 996afea7f..0e7f52f52 100644
--- a/pkg/envoy/accesslog/dynamic_metadata_operator_test.go
+++ b/pkg/envoy/accesslog/v2/dynamic_metadata_operator_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 )
 
 var _ = Describe("DynamicMetadataOperator", func() {
diff --git a/pkg/envoy/accesslog/field_operator_test.go b/pkg/envoy/accesslog/v2/field_operator_test.go
similarity index 99%
rename from pkg/envoy/accesslog/field_operator_test.go
rename to pkg/envoy/accesslog/v2/field_operator_test.go
index 34510f9a8..407f80f3b 100644
--- a/pkg/envoy/accesslog/field_operator_test.go
+++ b/pkg/envoy/accesslog/v2/field_operator_test.go
@@ -1,4 +1,4 @@
-package accesslog_test
+package v2_test
 
 import (
 	"time"
@@ -7,7 +7,7 @@ import (
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	"github.com/golang/protobuf/ptypes"
 	"github.com/golang/protobuf/ptypes/wrappers"
diff --git a/pkg/envoy/accesslog/filter_state_operator_test.go b/pkg/envoy/accesslog/v2/filter_state_operator_test.go
similarity index 93%
rename from pkg/envoy/accesslog/filter_state_operator_test.go
rename to pkg/envoy/accesslog/v2/filter_state_operator_test.go
index 366b2afa7..8ac9411f0 100644
--- a/pkg/envoy/accesslog/filter_state_operator_test.go
+++ b/pkg/envoy/accesslog/v2/filter_state_operator_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 )
 
 var _ = Describe("FilterStateOperator", func() {
diff --git a/pkg/envoy/accesslog/format_parser_test.go b/pkg/envoy/accesslog/v2/format_parser_test.go
similarity index 99%
rename from pkg/envoy/accesslog/format_parser_test.go
rename to pkg/envoy/accesslog/v2/format_parser_test.go
index 4444773fb..59b88507f 100644
--- a/pkg/envoy/accesslog/format_parser_test.go
+++ b/pkg/envoy/accesslog/v2/format_parser_test.go
@@ -1,4 +1,4 @@
-package accesslog_test
+package v2_test
 
 import (
 	"time"
@@ -7,7 +7,7 @@ import (
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	"github.com/golang/protobuf/ptypes"
 	"github.com/golang/protobuf/ptypes/wrappers"
diff --git a/pkg/envoy/accesslog/helper_test.go b/pkg/envoy/accesslog/v2/helper_test.go
similarity index 94%
rename from pkg/envoy/accesslog/helper_test.go
rename to pkg/envoy/accesslog/v2/helper_test.go
index 0e390d0b3..3cd29918a 100644
--- a/pkg/envoy/accesslog/helper_test.go
+++ b/pkg/envoy/accesslog/v2/helper_test.go
@@ -1,4 +1,4 @@
-package accesslog_test
+package v2_test
 
 import (
 	envoy_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
diff --git a/pkg/envoy/accesslog/placeholder_fragment_test.go b/pkg/envoy/accesslog/v2/placeholder_fragment_test.go
similarity index 98%
rename from pkg/envoy/accesslog/placeholder_fragment_test.go
rename to pkg/envoy/accesslog/v2/placeholder_fragment_test.go
index 9c750a3b6..0ddcf0664 100644
--- a/pkg/envoy/accesslog/placeholder_fragment_test.go
+++ b/pkg/envoy/accesslog/v2/placeholder_fragment_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v2"
 )
diff --git a/pkg/envoy/accesslog/request_header_operator_test.go b/pkg/envoy/accesslog/v2/request_header_operator_test.go
similarity index 99%
rename from pkg/envoy/accesslog/request_header_operator_test.go
rename to pkg/envoy/accesslog/v2/request_header_operator_test.go
index 1720eeab4..a873071ed 100644
--- a/pkg/envoy/accesslog/request_header_operator_test.go
+++ b/pkg/envoy/accesslog/v2/request_header_operator_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	envoy_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
 
diff --git a/pkg/envoy/accesslog/response_header_operator_test.go b/pkg/envoy/accesslog/v2/response_header_operator_test.go
similarity index 99%
rename from pkg/envoy/accesslog/response_header_operator_test.go
rename to pkg/envoy/accesslog/v2/response_header_operator_test.go
index 4c74bc4fc..30a8032ea 100644
--- a/pkg/envoy/accesslog/response_header_operator_test.go
+++ b/pkg/envoy/accesslog/v2/response_header_operator_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v2"
 	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v2"
diff --git a/pkg/envoy/accesslog/response_trailer_operator_test.go b/pkg/envoy/accesslog/v2/response_trailer_operator_test.go
similarity index 99%
rename from pkg/envoy/accesslog/response_trailer_operator_test.go
rename to pkg/envoy/accesslog/v2/response_trailer_operator_test.go
index 0d488ef24..cd24aa3b9 100644
--- a/pkg/envoy/accesslog/response_trailer_operator_test.go
+++ b/pkg/envoy/accesslog/v2/response_trailer_operator_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v2"
 	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v2"
diff --git a/pkg/envoy/accesslog/start_time_operator_test.go b/pkg/envoy/accesslog/v2/start_time_operator_test.go
similarity index 97%
rename from pkg/envoy/accesslog/start_time_operator_test.go
rename to pkg/envoy/accesslog/v2/start_time_operator_test.go
index 75c857fd4..bb77ae050 100644
--- a/pkg/envoy/accesslog/start_time_operator_test.go
+++ b/pkg/envoy/accesslog/v2/start_time_operator_test.go
@@ -1,4 +1,4 @@
-package accesslog_test
+package v2_test
 
 import (
 	"time"
@@ -7,7 +7,7 @@ import (
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	"github.com/golang/protobuf/ptypes/timestamp"
 
diff --git a/pkg/envoy/accesslog/text_span_fragment_test.go b/pkg/envoy/accesslog/v2/text_span_fragment_test.go
similarity index 95%
rename from pkg/envoy/accesslog/text_span_fragment_test.go
rename to pkg/envoy/accesslog/v2/text_span_fragment_test.go
index 42c9f3aa8..f2132d255 100644
--- a/pkg/envoy/accesslog/text_span_fragment_test.go
+++ b/pkg/envoy/accesslog/v2/text_span_fragment_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 
 	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v2"
 )
diff --git a/pkg/envoy/accesslog/tls_cipher_suites_test.go b/pkg/envoy/accesslog/v2/tls_cipher_suites_test.go
similarity index 98%
rename from pkg/envoy/accesslog/tls_cipher_suites_test.go
rename to pkg/envoy/accesslog/v2/tls_cipher_suites_test.go
index 3bc850d71..b1da533ce 100644
--- a/pkg/envoy/accesslog/tls_cipher_suites_test.go
+++ b/pkg/envoy/accesslog/v2/tls_cipher_suites_test.go
@@ -1,11 +1,11 @@
-package accesslog_test
+package v2_test
 
 import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/ginkgo/extensions/table"
 	. "github.com/onsi/gomega"
 
-	. "github.com/kumahq/kuma/pkg/envoy/accesslog"
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v2"
 )
 
 var _ = Describe("TlsCipherSuite", func() {
diff --git a/pkg/envoy/accesslog/v3/accesslog_suite_test.go b/pkg/envoy/accesslog/v3/accesslog_suite_test.go
new file mode 100644
index 000000000..9c5328fec
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/accesslog_suite_test.go
@@ -0,0 +1,13 @@
+package v3_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestAccesslog(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Accesslog Suite")
+}
diff --git a/pkg/envoy/accesslog/v3/dynamic_metadata_operator_test.go b/pkg/envoy/accesslog/v3/dynamic_metadata_operator_test.go
new file mode 100644
index 000000000..f7e1c3f87
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/dynamic_metadata_operator_test.go
@@ -0,0 +1,81 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+)
+
+var _ = Describe("DynamicMetadataOperator", func() {
+
+	Describe("String()", func() {
+		type testCase struct {
+			filterNamespace string
+			path            []string
+			maxLength       int
+			expected        string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := &DynamicMetadataOperator{FilterNamespace: given.filterNamespace, Path: given.path, MaxLength: given.maxLength}
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%DYNAMIC_METADATA()%", testCase{
+				expected: `%DYNAMIC_METADATA()%`,
+			}),
+			Entry("%DYNAMIC_METADATA():10%", testCase{
+				maxLength: 10,
+				expected:  `%DYNAMIC_METADATA():10%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter)%", testCase{
+				filterNamespace: "com.test.my_filter",
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter)%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter):10%", testCase{
+				filterNamespace: "com.test.my_filter",
+				maxLength:       10,
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter):10%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object)%", testCase{
+				filterNamespace: "com.test.my_filter",
+				path:            []string{"test_object"},
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter:test_object)%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object):10%", testCase{
+				filterNamespace: "com.test.my_filter",
+				path:            []string{"test_object"},
+				maxLength:       10,
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter:test_object):10%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key)%", testCase{
+				filterNamespace: "com.test.my_filter",
+				path:            []string{"test_object", "inner_key"},
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key)%`,
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%", testCase{
+				filterNamespace: "com.test.my_filter",
+				path:            []string{"test_object", "inner_key"},
+				maxLength:       10,
+				expected:        `%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%`,
+			}),
+			Entry("%DYNAMIC_METADATA(:test_object:inner_key)%", testCase{
+				path:     []string{"test_object", "inner_key"},
+				expected: `%DYNAMIC_METADATA(:test_object:inner_key)%`,
+			}),
+			Entry("%DYNAMIC_METADATA(:test_object:inner_key):10%", testCase{
+				path:      []string{"test_object", "inner_key"},
+				maxLength: 10,
+				expected:  `%DYNAMIC_METADATA(:test_object:inner_key):10%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/field_operator_test.go b/pkg/envoy/accesslog/v3/field_operator_test.go
new file mode 100644
index 000000000..89614a70f
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/field_operator_test.go
@@ -0,0 +1,937 @@
+package v3_test
+
+import (
+	"time"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	"github.com/golang/protobuf/ptypes"
+	"github.com/golang/protobuf/ptypes/wrappers"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+)
+
+var _ = Describe("FieldOperator", func() {
+
+	Describe("FormatHttpLogEntry()", func() {
+
+		type testCase struct {
+			field    string
+			entry    *accesslog_data.HTTPAccessLogEntry
+			expected string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// given
+				fragment := FieldOperator(given.field)
+				// when
+				actual, err := fragment.FormatHttpLogEntry(given.entry)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("BYTES_RECEIVED: 0", testCase{
+				field:    "BYTES_RECEIVED",
+				expected: `0`,
+			}),
+			Entry("BYTES_RECEIVED: 123", testCase{
+				field: "BYTES_RECEIVED",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Request: &accesslog_data.HTTPRequestProperties{
+						RequestBodyBytes: 123,
+					},
+				},
+				expected: `123`,
+			}),
+			Entry("BYTES_SENT: 0", testCase{
+				field:    "BYTES_SENT",
+				expected: `0`,
+			}),
+			Entry("BYTES_SENT: 456", testCase{
+				field: "BYTES_SENT",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Response: &accesslog_data.HTTPResponseProperties{
+						ResponseBodyBytes: 456,
+					},
+				},
+				expected: `456`,
+			}),
+			Entry("PROTOCOL: UNSPECIFIED", testCase{
+				field:    "PROTOCOL",
+				expected: ``,
+			}),
+			Entry("PROTOCOL: HTTP10", testCase{
+				field: "PROTOCOL",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					ProtocolVersion: accesslog_data.HTTPAccessLogEntry_HTTP10,
+				},
+				expected: `HTTP/1.0`,
+			}),
+			Entry("PROTOCOL: HTTP11", testCase{
+				field: "PROTOCOL",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					ProtocolVersion: accesslog_data.HTTPAccessLogEntry_HTTP11,
+				},
+				expected: `HTTP/1.1`,
+			}),
+			Entry("PROTOCOL: HTTP2", testCase{
+				field: "PROTOCOL",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					ProtocolVersion: accesslog_data.HTTPAccessLogEntry_HTTP2,
+				},
+				expected: `HTTP/2`,
+			}),
+			Entry("PROTOCOL: HTTP3", testCase{
+				field: "PROTOCOL",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					ProtocolVersion: accesslog_data.HTTPAccessLogEntry_HTTP3,
+				},
+				expected: `HTTP/3`,
+			}),
+			Entry("RESPONSE_CODE: 0", testCase{
+				field:    "RESPONSE_CODE",
+				expected: `0`,
+			}),
+			Entry("RESPONSE_CODE: 200", testCase{
+				field: "RESPONSE_CODE",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Response: &accesslog_data.HTTPResponseProperties{
+						ResponseCode: &wrappers.UInt32Value{
+							Value: 200,
+						},
+					},
+				},
+				expected: `200`,
+			}),
+			Entry("RESPONSE_CODE_DETAILS: ``", testCase{
+				field:    "RESPONSE_CODE_DETAILS",
+				expected: ``,
+			}),
+			Entry("RESPONSE_CODE_DETAILS: `response code details`", testCase{
+				field: "RESPONSE_CODE_DETAILS",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Response: &accesslog_data.HTTPResponseProperties{
+						ResponseCodeDetails: "response code details",
+					},
+				},
+				expected: `response code details`,
+			}),
+			Entry("REQUEST_DURATION: ``", testCase{
+				field:    "REQUEST_DURATION",
+				expected: ``,
+			}),
+			Entry("REQUEST_DURATION: `57` millis", testCase{
+				field: "REQUEST_DURATION",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToLastRxByte: ptypes.DurationProto(57000 * time.Microsecond),
+					},
+				},
+				expected: `57`, // time in millis
+			}),
+			Entry("RESPONSE_DURATION: ``", testCase{
+				field:    "RESPONSE_DURATION",
+				expected: ``,
+			}),
+			Entry("RESPONSE_DURATION: `102` millis", testCase{
+				field: "RESPONSE_DURATION",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte: ptypes.DurationProto(102000 * time.Microsecond),
+					},
+				},
+				expected: `102`, // time in millis
+			}),
+			Entry("RESPONSE_TX_DURATION: ``", testCase{
+				field:    "RESPONSE_TX_DURATION",
+				expected: ``,
+			}),
+			Entry("RESPONSE_TX_DURATION: no TimeToFirstUpstreamRxByte", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+					},
+				},
+				expected: ``,
+			}),
+			Entry("RESPONSE_TX_DURATION: no TimeToLastDownstreamTxByte", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte: ptypes.DurationProto(102000 * time.Microsecond),
+					},
+				},
+				expected: ``,
+			}),
+			Entry("RESPONSE_TX_DURATION: `23` millis", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte:  ptypes.DurationProto(102000 * time.Microsecond),
+						TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+					},
+				},
+				expected: `21`, // time in millis
+			}),
+			Entry("GRPC_STATUS: OK", testCase{
+				field: "GRPC_STATUS",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Response: &accesslog_data.HTTPResponseProperties{
+						ResponseTrailers: map[string]string{
+							"grpc-status": "0",
+						},
+					},
+				},
+				expected: "OK",
+			}),
+			Entry("GRPC_STATUS: no status", testCase{
+				field:    "GRPC_STATUS",
+				expected: "",
+			}),
+			Entry("GRPC_STATUS: InvalidCode", testCase{
+				field: "GRPC_STATUS",
+				entry: &accesslog_data.HTTPAccessLogEntry{
+					Response: &accesslog_data.HTTPResponseProperties{
+						ResponseTrailers: map[string]string{
+							"grpc-status": "",
+						},
+					},
+				},
+				expected: "InvalidCode",
+			}),
+		)
+	})
+
+	Describe("FormatTcpLogEntry()", func() {
+
+		type testCase struct {
+			field    string
+			entry    *accesslog_data.TCPAccessLogEntry
+			expected string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// given
+				fragment := FieldOperator(given.field)
+				// when
+				actual, err := fragment.FormatTcpLogEntry(given.entry)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("BYTES_RECEIVED: 0", testCase{
+				field:    "BYTES_RECEIVED",
+				expected: `0`,
+			}),
+			Entry("BYTES_RECEIVED: 123", testCase{
+				field: "BYTES_RECEIVED",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					ConnectionProperties: &accesslog_data.ConnectionProperties{
+						ReceivedBytes: 123,
+					},
+				},
+				expected: `123`,
+			}),
+			Entry("BYTES_SENT: 0", testCase{
+				field:    "BYTES_SENT",
+				expected: `0`,
+			}),
+			Entry("BYTES_SENT: 456", testCase{
+				field: "BYTES_SENT",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					ConnectionProperties: &accesslog_data.ConnectionProperties{
+						SentBytes: 456,
+					},
+				},
+				expected: `456`,
+			}),
+			Entry("PROTOCOL", testCase{
+				field:    "PROTOCOL",
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_CODE", testCase{
+				field:    "RESPONSE_CODE",
+				expected: `0`, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_CODE_DETAILS", testCase{
+				field:    "RESPONSE_CODE_DETAILS",
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("REQUEST_DURATION: ``", testCase{
+				field:    "REQUEST_DURATION",
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("REQUEST_DURATION: `57` millis", testCase{
+				field: "REQUEST_DURATION",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToLastRxByte: ptypes.DurationProto(57000 * time.Microsecond),
+					},
+				},
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_DURATION: ``", testCase{
+				field:    "RESPONSE_DURATION",
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_DURATION: `102` millis", testCase{
+				field: "RESPONSE_DURATION",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte: ptypes.DurationProto(102000 * time.Microsecond),
+					},
+				},
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_TX_DURATION: ``", testCase{
+				field:    "RESPONSE_TX_DURATION",
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_TX_DURATION: no TimeToFirstUpstreamRxByte", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+					},
+				},
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_TX_DURATION: no TimeToLastDownstreamTxByte", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte: ptypes.DurationProto(102000 * time.Microsecond),
+					},
+				},
+				expected: ``, // replicate Envoy's behaviour
+			}),
+			Entry("RESPONSE_TX_DURATION: `23` millis", testCase{
+				field: "RESPONSE_TX_DURATION",
+				entry: &accesslog_data.TCPAccessLogEntry{
+					CommonProperties: &accesslog_data.AccessLogCommon{
+						TimeToFirstUpstreamRxByte:  ptypes.DurationProto(102000 * time.Microsecond),
+						TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+					},
+				},
+				expected: ``, // replicate Envoy's behaviour
+			}),
+		)
+	})
+
+	Describe("FormatHttpLogEntry() and FormatTcpLogEntry()", func() {
+
+		type testCase struct {
+			field            string
+			commonProperties *accesslog_data.AccessLogCommon
+			expected         string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// given
+				fragment := FieldOperator(given.field)
+
+				// when
+				actual, err := fragment.FormatHttpLogEntry(&accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: given.commonProperties,
+				})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+
+				// when
+				actual, err = fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{
+					CommonProperties: given.commonProperties,
+				})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("UPSTREAM_TRANSPORT_FAILURE_REASON: ``", testCase{
+				field:    "UPSTREAM_TRANSPORT_FAILURE_REASON",
+				expected: ``,
+			}),
+			Entry("UPSTREAM_TRANSPORT_FAILURE_REASON: `mystery`", testCase{
+				field: "UPSTREAM_TRANSPORT_FAILURE_REASON",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					UpstreamTransportFailureReason: "mystery",
+				},
+				expected: `mystery`,
+			}),
+			Entry("DURATION: ``", testCase{
+				field:    "DURATION",
+				expected: ``,
+			}),
+			Entry("DURATION: `123`", testCase{
+				field: "DURATION",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+				},
+				expected: `123`,
+			}),
+			Entry("RESPONSE_FLAGS: ``", testCase{
+				field:    "RESPONSE_FLAGS",
+				expected: ``,
+			}),
+			Entry("RESPONSE_FLAGS: `FailedLocalHealthcheck`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						FailedLocalHealthcheck: true,
+					},
+				},
+				expected: `LH`,
+			}),
+			Entry("RESPONSE_FLAGS: `NoHealthyUpstream`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						NoHealthyUpstream: true,
+					},
+				},
+				expected: `UH`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamRequestTimeout`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamRequestTimeout: true,
+					},
+				},
+				expected: `UT`,
+			}),
+			Entry("RESPONSE_FLAGS: `LocalReset`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						LocalReset: true,
+					},
+				},
+				expected: `LR`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamRemoteReset`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamRemoteReset: true,
+					},
+				},
+				expected: `UR`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamConnectionFailure`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamConnectionFailure: true,
+					},
+				},
+				expected: `UF`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamConnectionTermination`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamConnectionTermination: true,
+					},
+				},
+				expected: `UC`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamOverflow`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamOverflow: true,
+					},
+				},
+				expected: `UO`,
+			}),
+			Entry("RESPONSE_FLAGS: `NoRouteFound`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						NoRouteFound: true,
+					},
+				},
+				expected: `NR`,
+			}),
+			Entry("RESPONSE_FLAGS: `DelayInjected`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						DelayInjected: true,
+					},
+				},
+				expected: `DI`,
+			}),
+			Entry("RESPONSE_FLAGS: `FaultInjected`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						FaultInjected: true,
+					},
+				},
+				expected: `FI`,
+			}),
+			Entry("RESPONSE_FLAGS: `RateLimited`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						RateLimited: true,
+					},
+				},
+				expected: `RL`,
+			}),
+			Entry("RESPONSE_FLAGS: `UnauthorizedDetails`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UnauthorizedDetails: &accesslog_data.ResponseFlags_Unauthorized{
+							Reason: accesslog_data.ResponseFlags_Unauthorized_REASON_UNSPECIFIED,
+						},
+					},
+				},
+				expected: ``,
+			}),
+			Entry("RESPONSE_FLAGS: `UnauthorizedExternalService`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UnauthorizedDetails: &accesslog_data.ResponseFlags_Unauthorized{
+							Reason: accesslog_data.ResponseFlags_Unauthorized_EXTERNAL_SERVICE,
+						},
+					},
+				},
+				expected: `UAEX`,
+			}),
+			Entry("RESPONSE_FLAGS: `RateLimitServiceError`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						RateLimitServiceError: true,
+					},
+				},
+				expected: `RLSE`,
+			}),
+			Entry("RESPONSE_FLAGS: `DownstreamConnectionTermination`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						DownstreamConnectionTermination: true,
+					},
+				},
+				expected: `DC`,
+			}),
+			Entry("RESPONSE_FLAGS: `UpstreamRetryLimitExceeded`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						UpstreamRetryLimitExceeded: true,
+					},
+				},
+				expected: `URX`,
+			}),
+			Entry("RESPONSE_FLAGS: `StreamIdleTimeout`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						StreamIdleTimeout: true,
+					},
+				},
+				expected: `SI`,
+			}),
+			Entry("RESPONSE_FLAGS: `InvalidEnvoyRequestHeaders`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						InvalidEnvoyRequestHeaders: true,
+					},
+				},
+				expected: `IH`,
+			}),
+			Entry("RESPONSE_FLAGS: `DownstreamProtocolError`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						DownstreamProtocolError: true,
+					},
+				},
+				expected: `DPE`,
+			}),
+			Entry("RESPONSE_FLAGS: all`", testCase{
+				field: "RESPONSE_FLAGS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					ResponseFlags: &accesslog_data.ResponseFlags{
+						FailedLocalHealthcheck:        true,
+						NoHealthyUpstream:             true,
+						UpstreamRequestTimeout:        true,
+						LocalReset:                    true,
+						UpstreamRemoteReset:           true,
+						UpstreamConnectionFailure:     true,
+						UpstreamConnectionTermination: true,
+						UpstreamOverflow:              true,
+						NoRouteFound:                  true,
+						DelayInjected:                 true,
+						FaultInjected:                 true,
+						RateLimited:                   true,
+						UnauthorizedDetails: &accesslog_data.ResponseFlags_Unauthorized{
+							Reason: accesslog_data.ResponseFlags_Unauthorized_EXTERNAL_SERVICE,
+						},
+						RateLimitServiceError:           true,
+						DownstreamConnectionTermination: true,
+						UpstreamRetryLimitExceeded:      true,
+						StreamIdleTimeout:               true,
+						InvalidEnvoyRequestHeaders:      true,
+						DownstreamProtocolError:         true,
+					},
+				},
+				expected: `LH,UH,UT,LR,UR,UF,UC,UO,NR,DI,FI,RL,UAEX,RLSE,DC,URX,SI,IH,DPE`,
+			}),
+			Entry("UPSTREAM_HOST: ``", testCase{
+				field:    "UPSTREAM_HOST",
+				expected: ``,
+			}),
+			Entry("UPSTREAM_HOST: `outbound:backend`", testCase{
+				field: "UPSTREAM_HOST",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					UpstreamRemoteAddress: EnvoySocketAddress("10.0.0.2", 443),
+				},
+				expected: `10.0.0.2:443`,
+			}),
+			Entry("UPSTREAM_CLUSTER: ``", testCase{
+				field:    "UPSTREAM_CLUSTER",
+				expected: ``,
+			}),
+			Entry("UPSTREAM_CLUSTER: `outbound:backend`", testCase{
+				field: "UPSTREAM_CLUSTER",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					UpstreamCluster: "outbound:backend",
+				},
+				expected: `outbound:backend`,
+			}),
+			Entry("UPSTREAM_LOCAL_ADDRESS: ``", testCase{
+				field:    "UPSTREAM_LOCAL_ADDRESS",
+				expected: ``,
+			}),
+			Entry("UPSTREAM_LOCAL_ADDRESS: `127.0.0.2:10001`", testCase{
+				field: "UPSTREAM_LOCAL_ADDRESS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					UpstreamLocalAddress: EnvoySocketAddress("127.0.0.2", 10001),
+				},
+				expected: `127.0.0.2:10001`,
+			}),
+			Entry("DOWNSTREAM_LOCAL_ADDRESS: ``", testCase{
+				field:    "DOWNSTREAM_LOCAL_ADDRESS",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_LOCAL_ADDRESS: `127.0.0.1:10000`", testCase{
+				field: "DOWNSTREAM_LOCAL_ADDRESS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamLocalAddress: EnvoySocketAddress("127.0.0.1", 10000),
+				},
+				expected: `127.0.0.1:10000`,
+			}),
+			Entry("DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT: ``", testCase{
+				field:    "DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT: `127.0.0.1`", testCase{
+				field: "DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamLocalAddress: EnvoySocketAddress("127.0.0.1", 10000),
+				},
+				expected: `127.0.0.1`,
+			}),
+			Entry("DOWNSTREAM_REMOTE_ADDRESS: ``", testCase{
+				field:    "DOWNSTREAM_REMOTE_ADDRESS",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_REMOTE_ADDRESS: `127.0.0.3:53165`", testCase{
+				field: "DOWNSTREAM_REMOTE_ADDRESS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamRemoteAddress: EnvoySocketAddress("127.0.0.3", 53165),
+				},
+				expected: `127.0.0.3:53165`,
+			}),
+			Entry("DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT: ``", testCase{
+				field:    "DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT: `127.0.0.3`", testCase{
+				field: "DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamRemoteAddress: EnvoySocketAddress("127.0.0.3", 53165),
+				},
+				expected: `127.0.0.3`,
+			}),
+			Entry("DOWNSTREAM_DIRECT_REMOTE_ADDRESS: ``", testCase{
+				field:    "DOWNSTREAM_DIRECT_REMOTE_ADDRESS",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_DIRECT_REMOTE_ADDRESS: `127.0.0.1:53166`", testCase{
+				field: "DOWNSTREAM_DIRECT_REMOTE_ADDRESS",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamDirectRemoteAddress: EnvoySocketAddress("127.0.0.1", 53166),
+				},
+				expected: `127.0.0.1:53166`,
+			}),
+			Entry("DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT: ``", testCase{
+				field:    "DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT: `127.0.0.4`", testCase{
+				field: "DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					DownstreamDirectRemoteAddress: EnvoySocketAddress("127.0.0.4", 53166),
+				},
+				expected: `127.0.0.4`,
+			}),
+			Entry("REQUESTED_SERVER_NAME: ``", testCase{
+				field:    "REQUESTED_SERVER_NAME",
+				expected: ``,
+			}),
+			Entry("REQUESTED_SERVER_NAME: `backend.internal`", testCase{
+				field: "REQUESTED_SERVER_NAME",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsSniHostname: "backend.internal",
+					},
+				},
+				expected: `backend.internal`,
+			}),
+			Entry("ROUTE_NAME: ``", testCase{
+				field:    "ROUTE_NAME",
+				expected: ``,
+			}),
+			Entry("ROUTE_NAME: `outbound:backend`", testCase{
+				field: "ROUTE_NAME",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					RouteName: "outbound:backend",
+				},
+				expected: `outbound:backend`,
+			}),
+			Entry("DOWNSTREAM_PEER_URI_SAN: ``", testCase{
+				field:    "DOWNSTREAM_PEER_URI_SAN",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_PEER_URI_SAN: `spiffe://default/web,spiffe://default/web-admin`", testCase{
+				field: "DOWNSTREAM_PEER_URI_SAN",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						PeerCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+							SubjectAltName: []*accesslog_data.TLSProperties_CertificateProperties_SubjectAltName{
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+										Uri: "spiffe://default/web",
+									},
+								},
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Dns{
+										Dns: "web.internal",
+									},
+								},
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+										Uri: "spiffe://default/web-admin",
+									},
+								},
+							},
+						},
+					},
+				},
+				expected: `spiffe://default/web,spiffe://default/web-admin`,
+			}),
+			Entry("DOWNSTREAM_LOCAL_URI_SAN: ``", testCase{
+				field:    "DOWNSTREAM_LOCAL_URI_SAN",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_LOCAL_URI_SAN: `spiffe://default/backend,spiffe://default/backend-admin`", testCase{
+				field: "DOWNSTREAM_LOCAL_URI_SAN",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						LocalCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+							SubjectAltName: []*accesslog_data.TLSProperties_CertificateProperties_SubjectAltName{
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+										Uri: "spiffe://default/backend",
+									},
+								},
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Dns{
+										Dns: "backend.internal",
+									},
+								},
+								{
+									San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+										Uri: "spiffe://default/backend-admin",
+									},
+								},
+							},
+						},
+					},
+				},
+				expected: `spiffe://default/backend,spiffe://default/backend-admin`,
+			}),
+			Entry("DOWNSTREAM_PEER_SUBJECT: ``", testCase{
+				field:    "DOWNSTREAM_PEER_SUBJECT",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_PEER_SUBJECT: `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`", testCase{
+				field: "DOWNSTREAM_PEER_SUBJECT",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						PeerCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+							Subject: `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+						},
+					},
+				},
+				expected: `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+			}),
+			Entry("DOWNSTREAM_LOCAL_SUBJECT: ``", testCase{
+				field:    "DOWNSTREAM_LOCAL_SUBJECT",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_LOCAL_SUBJECT: `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`", testCase{
+				field: "DOWNSTREAM_LOCAL_SUBJECT",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						LocalCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+							Subject: `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+						},
+					},
+				},
+				expected: `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+			}),
+			Entry("DOWNSTREAM_TLS_SESSION_ID: ``", testCase{
+				field:    "DOWNSTREAM_TLS_SESSION_ID",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_TLS_SESSION_ID: `b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901`", testCase{
+				field: "DOWNSTREAM_TLS_SESSION_ID",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsSessionId: "b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901",
+					},
+				},
+				expected: `b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901`,
+			}),
+			Entry("DOWNSTREAM_TLS_CIPHER: ``", testCase{
+				field:    "DOWNSTREAM_TLS_CIPHER",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_TLS_CIPHER: `ECDHE-RSA-CHACHA20-POLY1305`", testCase{
+				field: "DOWNSTREAM_TLS_CIPHER",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsCipherSuite: &wrappers.UInt32Value{
+							Value: uint32(TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305),
+						},
+					},
+				},
+				expected: `ECDHE-RSA-CHACHA20-POLY1305`,
+			}),
+			Entry("DOWNSTREAM_TLS_VERSION: ``", testCase{
+				field:    "DOWNSTREAM_TLS_VERSION",
+				expected: ``,
+			}),
+			Entry("DOWNSTREAM_TLS_VERSION: `TLSv1`", testCase{
+				field: "DOWNSTREAM_TLS_VERSION",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsVersion: accesslog_data.TLSProperties_TLSv1,
+					},
+				},
+				expected: `TLSv1`,
+			}),
+			Entry("DOWNSTREAM_TLS_VERSION: `TLSv1.1`", testCase{
+				field: "DOWNSTREAM_TLS_VERSION",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsVersion: accesslog_data.TLSProperties_TLSv1_1,
+					},
+				},
+				expected: `TLSv1.1`,
+			}),
+			Entry("DOWNSTREAM_TLS_VERSION: `TLSv1.2`", testCase{
+				field: "DOWNSTREAM_TLS_VERSION",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsVersion: accesslog_data.TLSProperties_TLSv1_2,
+					},
+				},
+				expected: `TLSv1.2`,
+			}),
+			Entry("DOWNSTREAM_TLS_VERSION: `TLSv1.3`", testCase{
+				field: "DOWNSTREAM_TLS_VERSION",
+				commonProperties: &accesslog_data.AccessLogCommon{
+					TlsProperties: &accesslog_data.TLSProperties{
+						TlsVersion: accesslog_data.TLSProperties_TLSv1_3,
+					},
+				},
+				expected: `TLSv1.3`,
+			}),
+			Entry("DOWNSTREAM_PEER_FINGERPRINT_256", testCase{
+				field:    "DOWNSTREAM_PEER_FINGERPRINT_256",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_FINGERPRINT_256%)`,
+			}),
+			Entry("DOWNSTREAM_PEER_SERIAL", testCase{
+				field:    "DOWNSTREAM_PEER_SERIAL",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_SERIAL%)`,
+			}),
+			Entry("DOWNSTREAM_PEER_ISSUER", testCase{
+				field:    "DOWNSTREAM_PEER_ISSUER",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_ISSUER%)`,
+			}),
+			Entry("DOWNSTREAM_PEER_CERT", testCase{
+				field:    "DOWNSTREAM_PEER_CERT",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT%)`,
+			}),
+			Entry("DOWNSTREAM_PEER_CERT_V_START", testCase{
+				field:    "DOWNSTREAM_PEER_CERT_V_START",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_START%)`,
+			}),
+			Entry("DOWNSTREAM_PEER_CERT_V_END", testCase{
+				field:    "DOWNSTREAM_PEER_CERT_V_END",
+				expected: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_END%)`,
+			}),
+			Entry("HOSTNAME", testCase{
+				field:    "HOSTNAME",
+				expected: `UNSUPPORTED_COMMAND(%HOSTNAME%)`,
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			field    string
+			expected string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := FieldOperator(given.field)
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%BYTES_RECEIVED%", testCase{
+				field:    "BYTES_RECEIVED",
+				expected: `%BYTES_RECEIVED%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/filter_state_operator_test.go b/pkg/envoy/accesslog/v3/filter_state_operator_test.go
new file mode 100644
index 000000000..13a15361e
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/filter_state_operator_test.go
@@ -0,0 +1,45 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+)
+
+var _ = Describe("FilterStateOperator", func() {
+
+	Describe("String()", func() {
+		type testCase struct {
+			key       string
+			maxLength int
+			expected  string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := &FilterStateOperator{Key: given.key, MaxLength: given.maxLength}
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%FILTER_STATE()%", testCase{
+				expected: `%FILTER_STATE()%`,
+			}),
+			Entry("%FILTER_STATE(filter.state.key)%", testCase{
+				key:      "filter.state.key",
+				expected: `%FILTER_STATE(filter.state.key)%`,
+			}),
+			Entry("%FILTER_STATE(filter.state.key):10%", testCase{
+				key:       "filter.state.key",
+				maxLength: 10,
+				expected:  `%FILTER_STATE(filter.state.key):10%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/format_parser_test.go b/pkg/envoy/accesslog/v3/format_parser_test.go
new file mode 100644
index 000000000..c1aee4e82
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/format_parser_test.go
@@ -0,0 +1,1046 @@
+package v3_test
+
+import (
+	"time"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	"github.com/golang/protobuf/ptypes"
+	"github.com/golang/protobuf/ptypes/wrappers"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+
+	util_proto "github.com/kumahq/kuma/pkg/util/proto"
+)
+
+var _ = Describe("ParseFormat()", func() {
+
+	Context("valid format string", func() {
+
+		commonProperties := &accesslog_data.AccessLogCommon{
+			StartTime:                  util_proto.MustTimestampProto(time.Unix(1582062737, 987654321)),
+			TimeToLastRxByte:           ptypes.DurationProto(57000 * time.Microsecond),
+			TimeToFirstUpstreamRxByte:  ptypes.DurationProto(102000 * time.Microsecond),
+			TimeToLastDownstreamTxByte: ptypes.DurationProto(123000 * time.Microsecond),
+			ResponseFlags: &accesslog_data.ResponseFlags{
+				UpstreamConnectionFailure:  true,
+				UpstreamRetryLimitExceeded: true,
+			},
+			DownstreamLocalAddress:         EnvoySocketAddress("127.0.0.1", 10000),
+			DownstreamRemoteAddress:        EnvoySocketAddress("127.0.0.3", 53165),
+			DownstreamDirectRemoteAddress:  EnvoySocketAddress("127.0.0.4", 53166),
+			UpstreamCluster:                "outbound:backend",
+			UpstreamLocalAddress:           EnvoySocketAddress("127.0.0.2", 10001),
+			UpstreamRemoteAddress:          EnvoySocketAddress("10.0.0.2", 443),
+			UpstreamTransportFailureReason: "mystery",
+			RouteName:                      "outbound:backend",
+			TlsProperties: &accesslog_data.TLSProperties{
+				TlsSniHostname: "backend.internal",
+				PeerCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+					Subject: `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+					SubjectAltName: []*accesslog_data.TLSProperties_CertificateProperties_SubjectAltName{
+						{
+							San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+								Uri: "spiffe://default/web",
+							},
+						},
+					},
+				},
+				LocalCertificateProperties: &accesslog_data.TLSProperties_CertificateProperties{
+					Subject: `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+					SubjectAltName: []*accesslog_data.TLSProperties_CertificateProperties_SubjectAltName{
+						{
+							San: &accesslog_data.TLSProperties_CertificateProperties_SubjectAltName_Uri{
+								Uri: "spiffe://default/backend",
+							},
+						},
+					},
+				},
+				TlsSessionId: "b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901",
+				TlsVersion:   accesslog_data.TLSProperties_TLSv1_2,
+				TlsCipherSuite: &wrappers.UInt32Value{
+					Value: uint32(TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305),
+				},
+			},
+		}
+
+		httpExample := &accesslog_data.HTTPAccessLogEntry{
+			CommonProperties: commonProperties,
+			ProtocolVersion:  accesslog_data.HTTPAccessLogEntry_HTTP11,
+			Request: &accesslog_data.HTTPRequestProperties{
+				Scheme:           "https",
+				Authority:        "backend.internal:8080",
+				Path:             "/api",
+				RequestBodyBytes: 234,
+			},
+			Response: &accesslog_data.HTTPResponseProperties{
+				ResponseCode: &wrappers.UInt32Value{
+					Value: 200,
+				},
+				ResponseCodeDetails: "response code details",
+				ResponseHeaders: map[string]string{
+					"server":       "Tomcat",
+					"content-type": "application/json",
+				},
+				ResponseTrailers: map[string]string{
+					"grpc-status":  "14",
+					"grpc-message": "unavailable",
+				},
+				ResponseBodyBytes: 567,
+			},
+		}
+
+		tcpExample := &accesslog_data.TCPAccessLogEntry{
+			CommonProperties: commonProperties,
+			ConnectionProperties: &accesslog_data.ConnectionProperties{
+				ReceivedBytes: 234,
+				SentBytes:     567,
+			},
+		}
+
+		type testCase struct {
+			format       string
+			expectedHTTP string
+			expectedTCP  string
+		}
+
+		DescribeTable("should succefully parse valid format string",
+			func(given testCase) {
+				// when
+				format, err := ParseFormat(given.format)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+
+				// when
+				actual, err := format.FormatHttpLogEntry(httpExample)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expectedHTTP))
+
+				// when
+				actual, err = format.FormatTcpLogEntry(tcpExample)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expectedTCP))
+			},
+			Entry("empty string", testCase{
+				format:       "",
+				expectedHTTP: ``,
+				expectedTCP:  ``,
+			}),
+			Entry("literal string", testCase{
+				format:       `text without Envoy command operators`,
+				expectedHTTP: `text without Envoy command operators`,
+				expectedTCP:  `text without Envoy command operators`,
+			}),
+			Entry("%START_TIME%", testCase{
+				format:       `%START_TIME%`,
+				expectedHTTP: `2020-02-18T21:52:17.987Z`,
+				expectedTCP:  `2020-02-18T21:52:17.987Z`,
+			}),
+			Entry("%START_TIME()%", testCase{
+				format:       `%START_TIME%`,
+				expectedHTTP: `2020-02-18T21:52:17.987Z`,
+				expectedTCP:  `2020-02-18T21:52:17.987Z`,
+			}),
+			Entry("%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%", testCase{
+				format:       `%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%`, // user-defined format is not supported yet
+				expectedHTTP: `2020-02-18T21:52:17.987Z`,
+				expectedTCP:  `2020-02-18T21:52:17.987Z`,
+			}),
+			Entry("%START_TIME(%s.%3f)%", testCase{
+				format:       `%START_TIME(%s.%3f)%`, // user-defined format is not supported yet
+				expectedHTTP: `2020-02-18T21:52:17.987Z`,
+				expectedTCP:  `2020-02-18T21:52:17.987Z`,
+			}),
+			Entry("%BYTES_RECEIVED%", testCase{
+				format:       `%BYTES_RECEIVED%`,
+				expectedHTTP: `234`,
+				expectedTCP:  `234`,
+			}),
+			Entry("%PROTOCOL%", testCase{
+				format:       `%PROTOCOL%`,
+				expectedHTTP: `HTTP/1.1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESPONSE_CODE%", testCase{
+				format:       `%RESPONSE_CODE%`,
+				expectedHTTP: `200`,
+				expectedTCP:  `0`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESPONSE_CODE_DETAILS%", testCase{
+				format:       `%RESPONSE_CODE_DETAILS%`,
+				expectedHTTP: `response code details`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%BYTES_SENT%", testCase{
+				format:       `%BYTES_SENT%`,
+				expectedHTTP: `567`,
+				expectedTCP:  `567`,
+			}),
+			Entry("%REQUEST_DURATION%", testCase{
+				format:       `%REQUEST_DURATION%`,
+				expectedHTTP: `57`, // time in millis
+				expectedTCP:  `-`,  // replicate Envoy's behaviour
+			}),
+			Entry("%RESPONSE_DURATION%", testCase{
+				format:       `%RESPONSE_DURATION%`,
+				expectedHTTP: `102`, // time in millis
+				expectedTCP:  `-`,   // replicate Envoy's behaviour
+			}),
+			Entry("%RESPONSE_TX_DURATION%", testCase{
+				format:       `%RESPONSE_TX_DURATION%`,
+				expectedHTTP: `21`, // time in millis
+				expectedTCP:  `-`,  // replicate Envoy's behaviour
+			}),
+			Entry("%UPSTREAM_TRANSPORT_FAILURE_REASON%", testCase{
+				format:       `%UPSTREAM_TRANSPORT_FAILURE_REASON%`,
+				expectedHTTP: `mystery`,
+				expectedTCP:  `mystery`,
+			}),
+			Entry("%DURATION%", testCase{
+				format:       `%DURATION%`,
+				expectedHTTP: `123`,
+				expectedTCP:  `123`,
+			}),
+			Entry("%RESPONSE_FLAGS%", testCase{
+				format:       `%RESPONSE_FLAGS%`,
+				expectedHTTP: `UF,URX`,
+				expectedTCP:  `UF,URX`,
+			}),
+			Entry("%REQ()%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%REQ()%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ():10%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%REQ():10%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(:authority)%", testCase{ // pseudo header
+				format:       `%REQ(:authority)%`,
+				expectedHTTP: `backend.internal:8080`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(:authority):7%", testCase{ // max length
+				format:       `%REQ(:authority):7%`,
+				expectedHTTP: `backend`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(x-missing-header?:authority)%", testCase{ // altHeader
+				format:       `%REQ(x-missing-header?:authority)%`,
+				expectedHTTP: `backend.internal:8080`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(x-missing-header?:authority):7%", testCase{ // altHeader w/ maxLen
+				format:       `%REQ(x-missing-header?:authority):7%`,
+				expectedHTTP: `backend`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(x-missing-header?:AUTHORITY):7%", testCase{ // uppercase altHeader w/ maxLen
+				format:       `%REQ(x-missing-header?:AUTHORITY):7%`,
+				expectedHTTP: `backend`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(:authority?:path)%", testCase{ // header over altHeader
+				format:       `%REQ(:authority?:path)%`,
+				expectedHTTP: `backend.internal:8080`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(:authority?:path):7%", testCase{ // header over altHeader w/ maxLen
+				format:       `%REQ(:authority?:path):7%`,
+				expectedHTTP: `backend`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%REQ(:AUTHORITY?:path):7%", testCase{ // uppercase header over altHeader w/ maxLen
+				format:       `%REQ(:AUTHORITY?:path):7%`,
+				expectedHTTP: `backend`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP()%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%RESP()%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP():10%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%RESP():10%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(server)%", testCase{ // pseudo header
+				format:       `%RESP(server)%`,
+				expectedHTTP: `Tomcat`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(server):3%", testCase{ // max length
+				format:       `%RESP(server):3%`,
+				expectedHTTP: `Tom`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(x-missing-header?server)%", testCase{ // altHeader
+				format:       `%RESP(x-missing-header?server)%`,
+				expectedHTTP: `Tomcat`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(x-missing-header?server):3%", testCase{ // altHeader w/ maxLen
+				format:       `%RESP(x-missing-header?server):3%`,
+				expectedHTTP: `Tom`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(x-missing-header?SERVER):3%", testCase{ // uppercase altHeader w/ maxLen
+				format:       `%RESP(x-missing-header?SERVER):3%`,
+				expectedHTTP: `Tom`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(server?content-type)%", testCase{ // header over altHeader
+				format:       `%RESP(server?:content-type)%`,
+				expectedHTTP: `Tomcat`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(server?content-type):3%", testCase{ // header over altHeader w/ maxLen
+				format:       `%RESP(server?content-type):3%`,
+				expectedHTTP: `Tom`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%RESP(SERVER?content-type):3%", testCase{ // uppercase header over altHeader w/ maxLen
+				format:       `%RESP(SERVER?content-type):3%`,
+				expectedHTTP: `Tom`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER()%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%TRAILER()%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER():10%", testCase{ // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+				format:       `%TRAILER():10%`,
+				expectedHTTP: `-`, // replicate Envoy's behaviour
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(grpc-status)%", testCase{ // pseudo header
+				format:       `%TRAILER(grpc-status)%`,
+				expectedHTTP: `14`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(grpc-status):1%", testCase{ // max length
+				format:       `%TRAILER(grpc-status):1%`,
+				expectedHTTP: `1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(x-missing-header?grpc-status)%", testCase{ // altHeader
+				format:       `%TRAILER(x-missing-header?grpc-status)%`,
+				expectedHTTP: `14`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(x-missing-header?grpc-status):1%", testCase{ // altHeader w/ maxLen
+				format:       `%TRAILER(x-missing-header?grpc-status):1%`,
+				expectedHTTP: `1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(x-missing-header?GRPC-STATUS):1%", testCase{ // uppercase altHeader w/ maxLen
+				format:       `%TRAILER(x-missing-header?GRPC-STATUS):1%`,
+				expectedHTTP: `1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(grpc-status?grpc-message)%", testCase{ // header over altHeader
+				format:       `%TRAILER(grpc-status?:grpc-message)%`,
+				expectedHTTP: `14`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(grpc-status?grpc-message):1%", testCase{ // header over altHeader w/ maxLen
+				format:       `%TRAILER(grpc-status?grpc-message):1%`,
+				expectedHTTP: `1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%TRAILER(GRPC-STATUS?grpc-message):1%", testCase{ // uppercase header over altHeader w/ maxLen
+				format:       `%TRAILER(GRPC-STATUS?grpc-message):1%`,
+				expectedHTTP: `1`,
+				expectedTCP:  `-`, // replicate Envoy's behaviour
+			}),
+			Entry("%DYNAMIC_METADATA()%", testCase{ // apparently, Envoy allows both `FilterNamespace` and `Path` to be empty
+				format:       `%DYNAMIC_METADATA()%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA():10%", testCase{ // apparently, Envoy allows both `FilterNamespace` and `Path` to be empty
+				format:       `%DYNAMIC_METADATA():10%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter)%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter)%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter):10%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter):10%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_key)%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter:test_key)%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_key):10%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter:test_key):10%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key)%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key)%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%", testCase{
+				format:       `%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%)`, // not supported yet
+			}),
+			Entry("%FILTER_STATE(key)%", testCase{
+				format:       `%FILTER_STATE(key)%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%FILTER_STATE(KEY):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%FILTER_STATE(KEY):Z%)`, // not supported yet
+			}),
+			Entry("%FILTER_STATE(key):10%", testCase{
+				format:       `%FILTER_STATE(key):10%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%FILTER_STATE(KEY):Z%)`, // not supported yet
+				expectedTCP:  `UNSUPPORTED_COMMAND(%FILTER_STATE(KEY):Z%)`, // not supported yet
+			}),
+			Entry("%UPSTREAM_HOST%", testCase{
+				format:       `%UPSTREAM_HOST%`,
+				expectedHTTP: `10.0.0.2:443`,
+				expectedTCP:  `10.0.0.2:443`,
+			}),
+			Entry("%UPSTREAM_CLUSTER%", testCase{
+				format:       `%UPSTREAM_CLUSTER%`,
+				expectedHTTP: `outbound:backend`,
+				expectedTCP:  `outbound:backend`,
+			}),
+			Entry("%UPSTREAM_LOCAL_ADDRESS%", testCase{
+				format:       `%UPSTREAM_LOCAL_ADDRESS%`,
+				expectedHTTP: `127.0.0.2:10001`,
+				expectedTCP:  `127.0.0.2:10001`,
+			}),
+			Entry("%DOWNSTREAM_LOCAL_ADDRESS%", testCase{
+				format:       `%DOWNSTREAM_LOCAL_ADDRESS%`,
+				expectedHTTP: `127.0.0.1:10000`,
+				expectedTCP:  `127.0.0.1:10000`,
+			}),
+			Entry("%DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT%", testCase{
+				format:       `%DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT%`,
+				expectedHTTP: `127.0.0.1`,
+				expectedTCP:  `127.0.0.1`,
+			}),
+			Entry("%DOWNSTREAM_REMOTE_ADDRESS%", testCase{
+				format:       `%DOWNSTREAM_REMOTE_ADDRESS%`,
+				expectedHTTP: `127.0.0.3:53165`,
+				expectedTCP:  `127.0.0.3:53165`,
+			}),
+			Entry("%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%", testCase{
+				format:       `%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%`,
+				expectedHTTP: `127.0.0.3`,
+				expectedTCP:  `127.0.0.3`,
+			}),
+			Entry("%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%", testCase{
+				format:       `%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%`,
+				expectedHTTP: `127.0.0.4:53166`,
+				expectedTCP:  `127.0.0.4:53166`,
+			}),
+			Entry("%DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT%", testCase{
+				format:       `%DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT%`,
+				expectedHTTP: `127.0.0.4`,
+				expectedTCP:  `127.0.0.4`,
+			}),
+			Entry("%REQUESTED_SERVER_NAME%", testCase{
+				format:       `%REQUESTED_SERVER_NAME%`,
+				expectedHTTP: `backend.internal`,
+				expectedTCP:  `backend.internal`,
+			}),
+			Entry("%ROUTE_NAME%", testCase{
+				format:       `%ROUTE_NAME%`,
+				expectedHTTP: `outbound:backend`,
+				expectedTCP:  `outbound:backend`,
+			}),
+			Entry("%DOWNSTREAM_PEER_URI_SAN%", testCase{
+				format:       `%DOWNSTREAM_PEER_URI_SAN%`,
+				expectedHTTP: `spiffe://default/web`,
+				expectedTCP:  `spiffe://default/web`,
+			}),
+			Entry("%DOWNSTREAM_LOCAL_URI_SAN%", testCase{
+				format:       `%DOWNSTREAM_LOCAL_URI_SAN%`,
+				expectedHTTP: `spiffe://default/backend`,
+				expectedTCP:  `spiffe://default/backend`,
+			}),
+			Entry("%DOWNSTREAM_PEER_SUBJECT%", testCase{
+				format:       `%DOWNSTREAM_PEER_SUBJECT%`,
+				expectedHTTP: `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+				expectedTCP:  `CN=web,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+			}),
+			Entry("%DOWNSTREAM_LOCAL_SUBJECT%", testCase{
+				format:       `%DOWNSTREAM_LOCAL_SUBJECT%`,
+				expectedHTTP: `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+				expectedTCP:  `CN=backend,OU=IT,O=Webshop,L=San Francisco,ST=California,C=US`,
+			}),
+			Entry("%DOWNSTREAM_TLS_SESSION_ID%", testCase{
+				format:       `%DOWNSTREAM_TLS_SESSION_ID%`,
+				expectedHTTP: `b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901`,
+				expectedTCP:  `b10662bf6bd4e6a068f0910d3d60c50f000355840fea4ce6844626b61c973901`,
+			}),
+			Entry("%DOWNSTREAM_TLS_CIPHER%", testCase{
+				format:       `%DOWNSTREAM_TLS_CIPHER%`,
+				expectedHTTP: `ECDHE-RSA-CHACHA20-POLY1305`,
+				expectedTCP:  `ECDHE-RSA-CHACHA20-POLY1305`,
+			}),
+			Entry("%DOWNSTREAM_TLS_VERSION%", testCase{
+				format:       `%DOWNSTREAM_TLS_VERSION%`,
+				expectedHTTP: `TLSv1.2`,
+				expectedTCP:  `TLSv1.2`,
+			}),
+			Entry("%DOWNSTREAM_PEER_FINGERPRINT_256%", testCase{
+				format:       `%DOWNSTREAM_PEER_FINGERPRINT_256%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_FINGERPRINT_256%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_FINGERPRINT_256%)`,
+			}),
+			Entry("%DOWNSTREAM_PEER_SERIAL%", testCase{
+				format:       `%DOWNSTREAM_PEER_SERIAL%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_SERIAL%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_SERIAL%)`,
+			}),
+			Entry("%DOWNSTREAM_PEER_ISSUER%", testCase{
+				format:       `%DOWNSTREAM_PEER_ISSUER%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_ISSUER%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_ISSUER%)`,
+			}),
+			Entry("%DOWNSTREAM_PEER_CERT%", testCase{
+				format:       `%DOWNSTREAM_PEER_CERT%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT%)`,
+			}),
+			Entry("%DOWNSTREAM_PEER_CERT_V_START%", testCase{
+				format:       `%DOWNSTREAM_PEER_CERT_V_START%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_START%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_START%)`,
+			}),
+			Entry("%DOWNSTREAM_PEER_CERT_V_END%", testCase{
+				format:       `%DOWNSTREAM_PEER_CERT_V_END%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_END%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%DOWNSTREAM_PEER_CERT_V_END%)`,
+			}),
+			Entry("%HOSTNAME%", testCase{
+				format:       `%HOSTNAME%`,
+				expectedHTTP: `UNSUPPORTED_COMMAND(%HOSTNAME%)`,
+				expectedTCP:  `UNSUPPORTED_COMMAND(%HOSTNAME%)`,
+			}),
+			Entry("%KUMA_SOURCE_ADDRESS%", testCase{
+				format:       `%KUMA_SOURCE_ADDRESS%`,
+				expectedHTTP: `%KUMA_SOURCE_ADDRESS%`, // placeholder must be rendered "as is"
+				expectedTCP:  `%KUMA_SOURCE_ADDRESS%`,
+			}),
+			Entry("%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%", testCase{
+				format:       `%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%`,
+				expectedHTTP: `%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%`, // placeholder must be rendered "as is"
+				expectedTCP:  `%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%`,
+			}),
+			Entry("%KUMA_SOURCE_SERVICE%", testCase{
+				format:       `%KUMA_SOURCE_SERVICE%`,
+				expectedHTTP: `%KUMA_SOURCE_SERVICE%`, // placeholder must be rendered "as is"
+				expectedTCP:  `%KUMA_SOURCE_SERVICE%`,
+			}),
+			Entry("%KUMA_DESTINATION_SERVICE%", testCase{
+				format:       `%KUMA_DESTINATION_SERVICE%`,
+				expectedHTTP: `%KUMA_DESTINATION_SERVICE%`, // placeholder must be rendered "as is"
+				expectedTCP:  `%KUMA_DESTINATION_SERVICE%`,
+			}),
+			Entry("composite", testCase{
+				format:       `[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%"`,
+				expectedHTTP: `[2020-02-18T21:52:17.987Z] "- /api HTTP/1.1" 200 UF,URX 234 567 123 - "-" "-" "-" "backend.internal:8080"`,
+				expectedTCP:  `[2020-02-18T21:52:17.987Z] "- - -" 0 UF,URX 234 567 123 - "-" "-" "-" "-"`,
+			}),
+			Entry("multi-line", testCase{
+				format: `
+[%START_TIME%]
+"%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%"
+%RESPONSE_CODE%
+%RESPONSE_FLAGS%
+%BYTES_RECEIVED%
+%BYTES_SENT%
+%DURATION%
+%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%
+"%REQ(X-FORWARDED-FOR)%"
+"%REQ(USER-AGENT)%"
+"%REQ(X-REQUEST-ID)%"
+"%REQ(:AUTHORITY)%"
+`,
+				expectedHTTP: `
+[2020-02-18T21:52:17.987Z]
+"- /api HTTP/1.1"
+200
+UF,URX
+234
+567
+123
+-
+"-"
+"-"
+"-"
+"backend.internal:8080"
+`,
+				expectedTCP: `
+[2020-02-18T21:52:17.987Z]
+"- - -"
+0
+UF,URX
+234
+567
+123
+-
+"-"
+"-"
+"-"
+"-"
+`,
+			}),
+		)
+	})
+
+	Context("invalid format string", func() {
+
+		type testCase struct {
+			format      string
+			expectedErr string
+		}
+
+		DescribeTable("should reject an invalid format string",
+			func(given testCase) {
+				// when
+				format, err := ParseFormat(given.format)
+				// then
+				Expect(format).To(BeNil())
+				// and
+				Expect(err).To(HaveOccurred())
+				// and
+				Expect(err.Error()).To(Equal(given.expectedErr))
+			},
+			Entry("unbalanced %", testCase{
+				format:      `text with % character`,
+				expectedErr: `format string is not valid: expected a command operator to start at position 11, instead got: "% character"`,
+			}),
+			Entry("%START_TIME(%", testCase{
+				format:      `%START_TIME(%`,
+				expectedErr: `format string is not valid: expected a command operator to start at position 1, instead got: "%START_TIME(%"`,
+			}),
+			Entry("%BYTES_RECEIVED()%", testCase{
+				format:      `%BYTES_RECEIVED()%`,
+				expectedErr: `format string is not valid: command "%BYTES_RECEIVED%" doesn't support arguments or max length constraint, instead got "%BYTES_RECEIVED()%"`,
+			}),
+			Entry("%REQ%", testCase{
+				format:      `%REQ%`,
+				expectedErr: `format string is not valid: command "%REQ(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%REQ%"`,
+			}),
+			Entry("%REQ:10%", testCase{
+				format:      `%REQ:10%`,
+				expectedErr: `format string is not valid: command "%REQ(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%REQ:10%"`,
+			}),
+			Entry("%REQ(header-1?header-2?header-3)%", testCase{
+				format:      `%REQ(header-1?header-2?header-3)%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%REQ(header-1?header-2?header-3)%"`,
+			}),
+			Entry("%REQ(header-1?header-2?header-3):10%", testCase{
+				format:      `%REQ(header-1?header-2?header-3):10%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%REQ(header-1?header-2?header-3):10%"`,
+			}),
+			Entry("%REQ(header-1\n?header-2)%", testCase{
+				format:      "%REQ(header-1\n?header-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%REQ(header-1\\n?header-2)%\"",
+			}),
+			Entry("%REQ(header-1?\rheader-2)%", testCase{
+				format:      "%REQ(header-1?\rheader-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%REQ(header-1?\\rheader-2)%\"",
+			}),
+			Entry("%REQ(header-1\x00)%", testCase{
+				format:      "%REQ(header-1\x00)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%REQ(header-1\\x00)%\"",
+			}),
+			Entry("%RESP%", testCase{
+				format:      `%RESP%`,
+				expectedErr: `format string is not valid: command "%RESP(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%RESP%"`,
+			}),
+			Entry("%RESP:10%", testCase{
+				format:      `%RESP:10%`,
+				expectedErr: `format string is not valid: command "%RESP(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%RESP:10%"`,
+			}),
+			Entry("%RESP(header-1?header-2?header-3)%", testCase{
+				format:      `%RESP(header-1?header-2?header-3)%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%RESP(header-1?header-2?header-3)%"`,
+			}),
+			Entry("%RESP(header-1?header-2?header-3):10%", testCase{
+				format:      `%RESP(header-1?header-2?header-3):10%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%RESP(header-1?header-2?header-3):10%"`,
+			}),
+			Entry("%RESP(header-1\n?header-2)%", testCase{
+				format:      "%RESP(header-1\n?header-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%RESP(header-1\\n?header-2)%\"",
+			}),
+			Entry("%RESP(header-1?\rheader-2)%", testCase{
+				format:      "%RESP(header-1?\rheader-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%RESP(header-1?\\rheader-2)%\"",
+			}),
+			Entry("%RESP(header-1\x00)%", testCase{
+				format:      "%RESP(header-1\x00)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%RESP(header-1\\x00)%\"",
+			}),
+			Entry("%TRAILER%", testCase{
+				format:      `%TRAILER%`,
+				expectedErr: `format string is not valid: command "%TRAILER(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%TRAILER%"`,
+			}),
+			Entry("%TRAILER:10%", testCase{
+				format:      `%TRAILER:10%`,
+				expectedErr: `format string is not valid: command "%TRAILER(X?Y):Z%" requires a header and optional alternative header names as its arguments, instead got "%TRAILER:10%"`,
+			}),
+			Entry("%TRAILER(header-1?header-2?header-3)%", testCase{
+				format:      `%TRAILER(header-1?header-2?header-3)%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%TRAILER(header-1?header-2?header-3)%"`,
+			}),
+			Entry("%TRAILER(header-1?header-2?header-3):10%", testCase{
+				format:      `%TRAILER(header-1?header-2?header-3):10%`,
+				expectedErr: `format string is not valid: more than 1 alternative header specified in "%TRAILER(header-1?header-2?header-3):10%"`,
+			}),
+			Entry("%TRAILER(header-1\n?header-2)%", testCase{
+				format:      "%TRAILER(header-1\n?header-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%TRAILER(header-1\\n?header-2)%\"",
+			}),
+			Entry("%TRAILER(header-1?\rheader-2)%", testCase{
+				format:      "%TRAILER(header-1?\rheader-2)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%TRAILER(header-1?\\rheader-2)%\"",
+			}),
+			Entry("%TRAILER(header-1\x00)%", testCase{
+				format:      "%TRAILER(header-1\x00)%",
+				expectedErr: "format string is not valid: header name contains a newline in \"%TRAILER(header-1\\x00)%\"",
+			}),
+			Entry("%DYNAMIC_METADATA%", testCase{
+				format:      `%DYNAMIC_METADATA%`,
+				expectedErr: `format string is not valid: command "%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%" requires a filter namespace and optional path as its arguments, instead got "%DYNAMIC_METADATA%"`,
+			}),
+			Entry("%DYNAMIC_METADATA:10%", testCase{
+				format:      `%DYNAMIC_METADATA:10%`,
+				expectedErr: `format string is not valid: command "%DYNAMIC_METADATA(NAMESPACE:KEY*):Z%" requires a filter namespace and optional path as its arguments, instead got "%DYNAMIC_METADATA:10%"`,
+			}),
+			Entry("%FILTER_STATE%", testCase{
+				format:      `%FILTER_STATE%`,
+				expectedErr: `format string is not valid: command "%FILTER_STATE(KEY):Z%" requires a key as its argument, instead got "%FILTER_STATE%"`,
+			}),
+			Entry("%FILTER_STATE:10%", testCase{
+				format:      `%FILTER_STATE:10%`,
+				expectedErr: `format string is not valid: command "%FILTER_STATE(KEY):Z%" requires a key as its argument, instead got "%FILTER_STATE:10%"`,
+			}),
+			Entry("%FILTER_STATE()%", testCase{
+				format:      `%FILTER_STATE()%`,
+				expectedErr: `format string is not valid: command "%FILTER_STATE(KEY):Z%" requires a key as its argument, instead got "%FILTER_STATE()%"`,
+			}),
+			Entry("%FILTER_STATE():10%", testCase{
+				format:      `%FILTER_STATE():10%`,
+				expectedErr: `format string is not valid: command "%FILTER_STATE(KEY):Z%" requires a key as its argument, instead got "%FILTER_STATE():10%"`,
+			}),
+		)
+	})
+
+	Context("support ConfigureHttpLog() and ConfigureTcpLog()", func() {
+
+		type testCase struct {
+			format       string
+			expectedHTTP *accesslog_config.HttpGrpcAccessLogConfig // verify the entire config to make sure there are no unexpected changes
+			expectedTCP  *accesslog_config.TcpGrpcAccessLogConfig  // verify the entire config to make sure there are no unexpected changes
+		}
+
+		DescribeTable("should configure properly",
+			func(given testCase) {
+				// when
+				format, err := ParseFormat(given.format)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+
+				// given
+				actualHTTP := &accesslog_config.HttpGrpcAccessLogConfig{}
+				// when
+				err = format.ConfigureHttpLog(actualHTTP)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actualHTTP).To(Equal(given.expectedHTTP))
+
+				// given
+				actualTCP := &accesslog_config.TcpGrpcAccessLogConfig{}
+				// when
+				err = format.ConfigureTcpLog(actualTCP)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actualTCP).To(Equal(given.expectedTCP))
+			},
+			Entry("commands without arguments should not have effect on the config", testCase{
+				format:       `%START_TIME% %PROTOCOL%`,
+				expectedHTTP: &accesslog_config.HttpGrpcAccessLogConfig{},
+				expectedTCP:  &accesslog_config.TcpGrpcAccessLogConfig{},
+			}),
+			Entry("commands with arguments should add them to the config", testCase{
+				format: `"%REQ(x-missing-header?:AUTHORITY):1%" "%RESP(DATE?server):2%" "%TRAILER(grpc-status?GRPC-MESSAGE):3%" "%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):4%" "%FILTER_STATE(filter.state.key):5%"`,
+				expectedHTTP: &accesslog_config.HttpGrpcAccessLogConfig{
+					CommonConfig: &accesslog_config.CommonGrpcAccessLogConfig{
+						FilterStateObjectsToLog: []string{"filter.state.key"},
+					},
+					AdditionalRequestHeadersToLog:   []string{"x-missing-header"}, // `:authority` header is captured by default and should not be added as an additional request header to log
+					AdditionalResponseHeadersToLog:  []string{"date", "server"},
+					AdditionalResponseTrailersToLog: []string{"grpc-status", "grpc-message"},
+				},
+				expectedTCP: &accesslog_config.TcpGrpcAccessLogConfig{
+					CommonConfig: &accesslog_config.CommonGrpcAccessLogConfig{
+						FilterStateObjectsToLog: []string{"filter.state.key"},
+					},
+				},
+			}),
+			Entry("config should not contain duplicate values", testCase{
+				format: `
+"%REQ(:AUTHORITY):1%" "%REQ(:path):2%" "%REQ(:authority):3%" 
+"%REQ(CONTENT-TYPE):1%" "%REQ(origin):2%" "%REQ(content-type):3%"
+"%RESP(SERVER):1%" "%RESP(content-type):2%" "%RESP(server):3%"
+"%TRAILER(GRPC-STATUS):1%" "%TRAILER(grpc-message):2%" "%TRAILER(grpc-status):3%"
+"%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key_1):1%" "%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key_2):2%" "%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key_1):3%"
+"%FILTER_STATE(filter.state.key1):1%" "%FILTER_STATE(filter.state.key2):2%" "%FILTER_STATE(filter.state.key1):3%"
+%BYTES_SENT%
+%KUMA_SOURCE_SERVICE%
+`,
+				expectedHTTP: &accesslog_config.HttpGrpcAccessLogConfig{
+					CommonConfig: &accesslog_config.CommonGrpcAccessLogConfig{
+						FilterStateObjectsToLog: []string{"filter.state.key1", "filter.state.key2"},
+					},
+					AdditionalRequestHeadersToLog:   []string{"content-type", "origin"}, // only those headers that are not captured by default should be added as additional request headers to log
+					AdditionalResponseHeadersToLog:  []string{"server", "content-type"},
+					AdditionalResponseTrailersToLog: []string{"grpc-status", "grpc-message"},
+				},
+				expectedTCP: &accesslog_config.TcpGrpcAccessLogConfig{
+					CommonConfig: &accesslog_config.CommonGrpcAccessLogConfig{
+						FilterStateObjectsToLog: []string{"filter.state.key1", "filter.state.key2"},
+					},
+				},
+			}),
+		)
+	})
+
+	Describe("support String()", func() {
+		type testCase struct {
+			format   string
+			expected string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// when
+				format, err := ParseFormat(given.format)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+
+				// when
+				actual := format.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("composite", testCase{
+				format:   `[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%"`,
+				expected: `[%START_TIME%] "%REQ(:method)% %REQ(x-envoy-original-path?:path)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(x-envoy-upstream-service-time)% "%REQ(x-forwarded-for)%" "%REQ(user-agent)%" "%REQ(x-request-id)%" "%REQ(:authority)%"`,
+			}),
+			Entry("multi-line", testCase{
+				format: `
+%START_TIME%
+%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%
+%REQ(:METHOD)%
+%RESP(content-type?SERVER):10%
+%TRAILER(PROTOCOL)%
+%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%
+%FILTER_STATE(filter.state.key):10%
+%BYTES_RECEIVED%
+%BYTES_RECEIVED%
+%BYTES_SENT%
+%PROTOCOL%
+%RESPONSE_CODE%
+%RESPONSE_CODE_DETAILS%
+%REQUEST_DURATION%
+%RESPONSE_DURATION%
+%RESPONSE_TX_DURATION%
+%DURATION%
+%RESPONSE_FLAGS%
+%UPSTREAM_HOST%
+%UPSTREAM_CLUSTER%
+%UPSTREAM_LOCAL_ADDRESS%
+%DOWNSTREAM_LOCAL_ADDRESS%
+%DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT%
+%DOWNSTREAM_REMOTE_ADDRESS%
+%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%
+%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%
+%DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT%
+%REQUESTED_SERVER_NAME%
+%ROUTE_NAME%
+%DOWNSTREAM_PEER_URI_SAN%
+%DOWNSTREAM_LOCAL_URI_SAN%
+%DOWNSTREAM_PEER_SUBJECT%
+%DOWNSTREAM_LOCAL_SUBJECT%
+%DOWNSTREAM_TLS_SESSION_ID%
+%DOWNSTREAM_TLS_CIPHER%
+%DOWNSTREAM_TLS_VERSION%
+%UPSTREAM_TRANSPORT_FAILURE_REASON%
+%DOWNSTREAM_PEER_FINGERPRINT_256%
+%DOWNSTREAM_PEER_SERIAL%
+%DOWNSTREAM_PEER_ISSUER%
+%DOWNSTREAM_PEER_CERT%
+%DOWNSTREAM_PEER_CERT_V_START%
+%DOWNSTREAM_PEER_CERT_V_END%
+%HOSTNAME%
+%KUMA_SOURCE_ADDRESS%
+%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+%KUMA_SOURCE_SERVICE%
+%KUMA_DESTINATION_SERVICE%
+`,
+				expected: `
+%START_TIME%
+%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%
+%REQ(:method)%
+%RESP(content-type?server):10%
+%TRAILER(protocol)%
+%DYNAMIC_METADATA(com.test.my_filter:test_object:inner_key):10%
+%FILTER_STATE(filter.state.key):10%
+%BYTES_RECEIVED%
+%BYTES_RECEIVED%
+%BYTES_SENT%
+%PROTOCOL%
+%RESPONSE_CODE%
+%RESPONSE_CODE_DETAILS%
+%REQUEST_DURATION%
+%RESPONSE_DURATION%
+%RESPONSE_TX_DURATION%
+%DURATION%
+%RESPONSE_FLAGS%
+%UPSTREAM_HOST%
+%UPSTREAM_CLUSTER%
+%UPSTREAM_LOCAL_ADDRESS%
+%DOWNSTREAM_LOCAL_ADDRESS%
+%DOWNSTREAM_LOCAL_ADDRESS_WITHOUT_PORT%
+%DOWNSTREAM_REMOTE_ADDRESS%
+%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%
+%DOWNSTREAM_DIRECT_REMOTE_ADDRESS%
+%DOWNSTREAM_DIRECT_REMOTE_ADDRESS_WITHOUT_PORT%
+%REQUESTED_SERVER_NAME%
+%ROUTE_NAME%
+%DOWNSTREAM_PEER_URI_SAN%
+%DOWNSTREAM_LOCAL_URI_SAN%
+%DOWNSTREAM_PEER_SUBJECT%
+%DOWNSTREAM_LOCAL_SUBJECT%
+%DOWNSTREAM_TLS_SESSION_ID%
+%DOWNSTREAM_TLS_CIPHER%
+%DOWNSTREAM_TLS_VERSION%
+%UPSTREAM_TRANSPORT_FAILURE_REASON%
+%DOWNSTREAM_PEER_FINGERPRINT_256%
+%DOWNSTREAM_PEER_SERIAL%
+%DOWNSTREAM_PEER_ISSUER%
+%DOWNSTREAM_PEER_CERT%
+%DOWNSTREAM_PEER_CERT_V_START%
+%DOWNSTREAM_PEER_CERT_V_END%
+%HOSTNAME%
+%KUMA_SOURCE_ADDRESS%
+%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+%KUMA_SOURCE_SERVICE%
+%KUMA_DESTINATION_SERVICE%
+`,
+			}),
+		)
+	})
+
+	Describe("support Interpolate()", func() {
+		type testCase struct {
+			format   string
+			context  map[string]string
+			expected string
+		}
+
+		DescribeTable("should bind to a given context",
+			func(given testCase) {
+				// when
+				format, err := ParseFormat(given.format)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+
+				// when
+				interpolatedFormat, err := format.Interpolate(InterpolationVariables(given.context))
+				// then
+				Expect(err).ToNot(HaveOccurred())
+
+				// when
+				actual := interpolatedFormat.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("multi-line w/ empty context", testCase{
+				format: `
+%START_TIME%
+%KUMA_SOURCE_ADDRESS%
+%DURATION%
+%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+%BYTES_RECEIVED%
+%KUMA_SOURCE_SERVICE%
+%BYTES_SENT%
+%KUMA_DESTINATION_SERVICE%
+%PROTOCOL%
+`,
+				context: nil,
+				expected: `
+%START_TIME%
+
+%DURATION%
+
+%BYTES_RECEIVED%
+
+%BYTES_SENT%
+
+%PROTOCOL%
+`,
+			}),
+			Entry("multi-line w/ full context", testCase{
+				format: `
+%START_TIME%
+%KUMA_SOURCE_ADDRESS%
+%DURATION%
+%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%
+%BYTES_RECEIVED%
+%KUMA_SOURCE_SERVICE%
+%BYTES_SENT%
+%KUMA_DESTINATION_SERVICE%
+%PROTOCOL%
+`,
+				context: map[string]string{
+					"KUMA_SOURCE_ADDRESS":              "10.0.0.3:0",
+					"KUMA_SOURCE_ADDRESS_WITHOUT_PORT": "10.0.0.3",
+					"KUMA_SOURCE_SERVICE":              "web",
+					"KUMA_DESTINATION_SERVICE":         "backend",
+				},
+				expected: `
+%START_TIME%
+10.0.0.3:0
+%DURATION%
+10.0.0.3
+%BYTES_RECEIVED%
+web
+%BYTES_SENT%
+backend
+%PROTOCOL%
+`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/helper_test.go b/pkg/envoy/accesslog/v3/helper_test.go
new file mode 100644
index 000000000..d4fe01b41
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/helper_test.go
@@ -0,0 +1,18 @@
+package v3_test
+
+import (
+	envoy_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+)
+
+func EnvoySocketAddress(address string, port uint32) *envoy_core.Address {
+	return &envoy_core.Address{
+		Address: &envoy_core.Address_SocketAddress{
+			SocketAddress: &envoy_core.SocketAddress{
+				Address: address,
+				PortSpecifier: &envoy_core.SocketAddress_PortValue{
+					PortValue: port,
+				},
+			},
+		},
+	}
+}
diff --git a/pkg/envoy/accesslog/v3/placeholder_fragment_test.go b/pkg/envoy/accesslog/v3/placeholder_fragment_test.go
new file mode 100644
index 000000000..46aaabd23
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/placeholder_fragment_test.go
@@ -0,0 +1,135 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+)
+
+var _ = Describe("Placeholder", func() {
+
+	Describe("FormatHttpLogEntry() and FormatTcpLogEntry()", func() {
+		type testCase struct {
+			variable string
+			expected string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := Placeholder(given.variable)
+
+				// when
+				actual, err := fragment.FormatHttpLogEntry(&accesslog_data.HTTPAccessLogEntry{})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+
+				// when
+				actual, err = fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("KUMA_SOURCE_ADDRESS", testCase{
+				variable: "KUMA_SOURCE_ADDRESS",
+				expected: `%KUMA_SOURCE_ADDRESS%`, // placeholder must be rendered "as is"
+			}),
+			Entry("KUMA_SOURCE_ADDRESS_WITHOUT_PORT", testCase{
+				variable: "KUMA_SOURCE_ADDRESS_WITHOUT_PORT",
+				expected: `%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%`, // placeholder must be rendered "as is"
+			}),
+			Entry("KUMA_SOURCE_SERVICE", testCase{
+				variable: "KUMA_SOURCE_SERVICE",
+				expected: `%KUMA_SOURCE_SERVICE%`, // placeholder must be rendered "as is"
+			}),
+			Entry("KUMA_DESTINATION_SERVICE", testCase{
+				variable: "KUMA_DESTINATION_SERVICE",
+				expected: `%KUMA_DESTINATION_SERVICE%`, // placeholder must be rendered "as is"
+			}),
+		)
+	})
+
+	Describe("Interpolate()", func() {
+		type testCase struct {
+			variable string
+			context  map[string]string
+			expected string
+		}
+
+		DescribeTable("should replace placeholder with a text literal",
+			func(given testCase) {
+				// setup
+				fragment := Placeholder(given.variable)
+
+				// when
+				actual, err := fragment.Interpolate(InterpolationVariables(given.context))
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(TextSpan(given.expected)))
+			},
+			Entry("`nil` context", testCase{
+				variable: "KUMA_SOURCE_SERVICE",
+				context:  nil,
+				expected: ``,
+			}),
+			Entry("variable w/o a value in the context", testCase{
+				variable: "KUMA_SOURCE_SERVICE",
+				context: map[string]string{
+					"KUMA_DESTINATION_SERVICE": "backend",
+				},
+				expected: ``,
+			}),
+			Entry("variable w/ a value in the context", testCase{
+				variable: "KUMA_SOURCE_SERVICE",
+				context: map[string]string{
+					"KUMA_SOURCE_SERVICE": "web",
+				},
+				expected: `web`,
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			variable string
+			expected string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := Placeholder(given.variable)
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("KUMA_SOURCE_ADDRESS", testCase{
+				variable: "KUMA_SOURCE_ADDRESS",
+				expected: `%KUMA_SOURCE_ADDRESS%`,
+			}),
+			Entry("KUMA_SOURCE_ADDRESS_WITHOUT_PORT", testCase{
+				variable: "KUMA_SOURCE_ADDRESS_WITHOUT_PORT",
+				expected: `%KUMA_SOURCE_ADDRESS_WITHOUT_PORT%`,
+			}),
+			Entry("KUMA_SOURCE_SERVICE", testCase{
+				variable: "KUMA_SOURCE_SERVICE",
+				expected: `%KUMA_SOURCE_SERVICE%`,
+			}),
+			Entry("KUMA_DESTINATION_SERVICE", testCase{
+				variable: "KUMA_DESTINATION_SERVICE",
+				expected: `%KUMA_DESTINATION_SERVICE%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/request_header_operator_test.go b/pkg/envoy/accesslog/v3/request_header_operator_test.go
new file mode 100644
index 000000000..bcf481e0d
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/request_header_operator_test.go
@@ -0,0 +1,390 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	envoy_core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+var _ = Describe("RequestHeaderOperator", func() {
+
+	Describe("FormatHttpLogEntry()", func() {
+		example := &accesslog_data.HTTPAccessLogEntry{
+			Request: &accesslog_data.HTTPRequestProperties{
+				RequestMethod:       envoy_core.RequestMethod_POST,
+				Scheme:              "https",
+				Authority:           "backend.internal:8080",
+				Path:                "/api/version",
+				UserAgent:           "curl",
+				Referer:             "www.google.com",
+				ForwardedFor:        "10.0.0.1",
+				RequestId:           "025169aa-8317-4ebd-b0dd-2f0872ec444a",
+				OriginalPath:        "/xyz",
+				RequestHeadersBytes: 123,
+				RequestBodyBytes:    456,
+				RequestHeaders: map[string]string{
+					"content-type": "application/json",
+				},
+			},
+		}
+
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			entry     *accesslog_data.HTTPAccessLogEntry
+			expected  string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := &RequestHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+				// when
+				actual, err := fragment.FormatHttpLogEntry(given.entry)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("no header or altHeader", testCase{
+				header:    "",
+				altHeader: "",
+				entry:     example,
+				expected:  ``, // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+			}),
+			Entry(":method - `POST`", testCase{
+				header:   ":method",
+				entry:    example,
+				expected: `POST`,
+			}),
+			Entry(":method - ``", testCase{
+				header:   ":method",
+				entry:    &accesslog_data.HTTPAccessLogEntry{},
+				expected: ``,
+			}),
+			Entry(":scheme", testCase{
+				header:   ":scheme",
+				entry:    example,
+				expected: `https`,
+			}),
+			Entry(":authority", testCase{
+				header:   ":authority",
+				entry:    example,
+				expected: `backend.internal:8080`,
+			}),
+			Entry(":path", testCase{
+				header:   ":path",
+				entry:    example,
+				expected: `/api/version`,
+			}),
+			Entry("user-agent", testCase{
+				header:   "user-agent",
+				entry:    example,
+				expected: `curl`,
+			}),
+			Entry("referer", testCase{
+				header:   "referer",
+				entry:    example,
+				expected: `www.google.com`,
+			}),
+			Entry("x-forwarded-for", testCase{
+				header:   "x-forwarded-for",
+				entry:    example,
+				expected: `10.0.0.1`,
+			}),
+			Entry("x-request-id", testCase{
+				header:   "x-request-id",
+				entry:    example,
+				expected: `025169aa-8317-4ebd-b0dd-2f0872ec444a`,
+			}),
+			Entry("x-envoy-original-path", testCase{
+				header:   "x-envoy-original-path",
+				entry:    example,
+				expected: `/xyz`,
+			}),
+			Entry("x-envoy-original-path", testCase{
+				header:   "x-envoy-original-path",
+				entry:    example,
+				expected: `/xyz`,
+			}),
+			Entry("content-type", testCase{
+				header:   "content-type",
+				entry:    example,
+				expected: `application/json`,
+			}),
+			Entry("origin", testCase{
+				header:   "origin", // missing header
+				entry:    example,
+				expected: ``,
+			}),
+			Entry("origin or :authority", testCase{
+				header:    "origin", // missing header
+				altHeader: ":authority",
+				entry:     example,
+				expected:  `backend.internal:8080`,
+			}),
+			Entry("origin or :authority w/ MaxLength", testCase{
+				header:    "origin", // missing header
+				altHeader: ":authority",
+				maxLength: 7,
+				entry:     example,
+				expected:  `backend`,
+			}),
+			Entry(":path or x-envoy-original-path", testCase{
+				header:    ":path",
+				altHeader: "x-envoy-original-path",
+				entry:     example,
+				expected:  `/api/version`,
+			}),
+			Entry(":path or x-envoy-original-path w/ MaxLength", testCase{
+				header:    ":path",
+				altHeader: "x-envoy-original-path",
+				maxLength: 5,
+				entry:     example,
+				expected:  `/api/`,
+			}),
+		)
+	})
+
+	Describe("FormatTcpLogEntry()", func() {
+		It("should always return an empty string", func() {
+			// setup
+			fragment := &RequestHeaderOperator{HeaderFormatter{
+				Header: ":path", AltHeader: "x-envoy-original-path", MaxLength: 123}}
+			// when
+			actual, err := fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{})
+			// then
+			Expect(err).ToNot(HaveOccurred())
+			// and
+			Expect(actual).To(Equal(``))
+		})
+	})
+
+	Describe("ConfigureHttpLog()", func() {
+
+		type testCase struct {
+			header    string
+			altHeader string
+			config    *accesslog_config.HttpGrpcAccessLogConfig
+			expected  *accesslog_config.HttpGrpcAccessLogConfig // verify the entire config to make sure there are no unexpected changes
+		}
+
+		DescribeTable("should configure properly",
+			func(given testCase) {
+				// setup
+				fragment := &RequestHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader}}
+				// when
+				err := fragment.ConfigureHttpLog(given.config)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(given.config).To(Equal(given.expected))
+			},
+			Entry("none", testCase{
+				header:    "",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{},
+			}),
+			Entry("header that is not captured by default", testCase{
+				header:    "content-type",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"content-type"},
+				},
+			}),
+			Entry("header that is captured by default: `:method`", testCase{
+				header:    ":method",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `:scheme`", testCase{
+				header:    ":scheme",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `:authority`", testCase{
+				header:    ":authority",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `:path`", testCase{
+				header:    ":path",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `user-agent`", testCase{
+				header:    "user-agent",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `referer`", testCase{
+				header:    "referer",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `x-forwarded-for`", testCase{
+				header:    "x-forwarded-for",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `x-request-id`", testCase{
+				header:    "x-request-id",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header that is captured by default: `x-envoy-original-path`", testCase{
+				header:    "x-envoy-original-path",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("altHeader that is not captured by default", testCase{
+				header:    "",
+				altHeader: "origin",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"origin"},
+				},
+			}),
+			Entry("altHeader that is captured by default", testCase{
+				header:    "",
+				altHeader: ":authority",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("header and altHeader that are not captured by default", testCase{
+				header:    "content-type",
+				altHeader: "origin",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"content-type", "origin"},
+				},
+			}),
+			Entry("header and altHeader that are captured by default", testCase{
+				header:    ":authority",
+				altHeader: ":path",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{}, // should not be added as an additional request header to log
+			}),
+			Entry("none w/ initial config", testCase{
+				header:    "",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2"},
+				},
+			}),
+			Entry("header w/ initial config", testCase{
+				header:    "content-type",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2", "content-type"},
+				},
+			}),
+			Entry("altHeader w/ initial config", testCase{
+				header:    "",
+				altHeader: "origin",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2", "origin"},
+				},
+			}),
+			Entry("header and altHeader w/ initial config", testCase{
+				header:    "content-type",
+				altHeader: "origin",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalRequestHeadersToLog: []string{"x-custom-header-1", "x-custom-header-2", "content-type", "origin"},
+				},
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			expected  string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := &RequestHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%REQ()%", testCase{
+				expected: `%REQ()%`,
+			}),
+			Entry("%REQ():10%", testCase{
+				maxLength: 10,
+				expected:  `%REQ():10%`,
+			}),
+			Entry("%REQ(:authority)%", testCase{
+				header:   `:authority`,
+				expected: `%REQ(:authority)%`,
+			}),
+			Entry("%REQ(:authority):10%", testCase{
+				header:    `:authority`,
+				maxLength: 10,
+				expected:  `%REQ(:authority):10%`,
+			}),
+			Entry("%REQ(?origin)%", testCase{
+				altHeader: `origin`,
+				expected:  `%REQ(?origin)%`,
+			}),
+			Entry("%REQ(?origin):10%", testCase{
+				altHeader: `origin`,
+				maxLength: 10,
+				expected:  `%REQ(?origin):10%`,
+			}),
+			Entry("%REQ(:authority?origin)%", testCase{
+				header:    ":authority",
+				altHeader: `origin`,
+				expected:  `%REQ(:authority?origin)%`,
+			}),
+			Entry("%REQ(:authority?origin):10%", testCase{
+				header:    ":authority",
+				altHeader: `origin`,
+				maxLength: 10,
+				expected:  `%REQ(:authority?origin):10%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/response_header_operator_test.go b/pkg/envoy/accesslog/v3/response_header_operator_test.go
new file mode 100644
index 000000000..76f4cbc69
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/response_header_operator_test.go
@@ -0,0 +1,257 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+var _ = Describe("ResponseHeaderOperator", func() {
+
+	Describe("FormatHttpLogEntry()", func() {
+		example := &accesslog_data.HTTPAccessLogEntry{
+			Response: &accesslog_data.HTTPResponseProperties{
+				ResponseHeaders: map[string]string{
+					"server":       "Tomcat",
+					"content-type": "application/json",
+				},
+			},
+		}
+
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			entry     *accesslog_data.HTTPAccessLogEntry
+			expected  string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+				// when
+				actual, err := fragment.FormatHttpLogEntry(given.entry)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("no header or altHeader", testCase{
+				header:    "",
+				altHeader: "",
+				entry:     example,
+				expected:  ``, // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+			}),
+			Entry("content-type", testCase{
+				header:   "content-type",
+				entry:    example,
+				expected: `application/json`,
+			}),
+			Entry("cache-control", testCase{
+				header:   "cache-control", // missing header
+				entry:    example,
+				expected: ``,
+			}),
+			Entry("cache-control or content-type", testCase{
+				header:    "cache-control", // missing header
+				altHeader: "content-type",
+				entry:     example,
+				expected:  `application/json`,
+			}),
+			Entry("cache-control or content-type w/ MaxLength", testCase{
+				header:    "cache-control", // missing header
+				altHeader: "content-type",
+				maxLength: 3,
+				entry:     example,
+				expected:  `app`,
+			}),
+			Entry("server or content-type", testCase{
+				header:    "server",
+				altHeader: "content-type",
+				entry:     example,
+				expected:  `Tomcat`,
+			}),
+			Entry("server or content-type w/ MaxLength", testCase{
+				header:    "server",
+				altHeader: "content-type",
+				maxLength: 3,
+				entry:     example,
+				expected:  `Tom`,
+			}),
+		)
+	})
+
+	Describe("FormatTcpLogEntry()", func() {
+		It("should always return an empty string", func() {
+			// setup
+			fragment := &ResponseHeaderOperator{HeaderFormatter{
+				Header: "content-type", AltHeader: "server", MaxLength: 123}}
+			// when
+			actual, err := fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{})
+			// then
+			Expect(err).ToNot(HaveOccurred())
+			// and
+			Expect(actual).To(Equal(``))
+		})
+	})
+
+	Describe("ConfigureHttpLog()", func() {
+
+		type testCase struct {
+			header    string
+			altHeader string
+			config    *accesslog_config.HttpGrpcAccessLogConfig
+			expected  *accesslog_config.HttpGrpcAccessLogConfig // verify the entire config to make sure there are no unexpected changes
+		}
+
+		DescribeTable("should configure properly",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader}}
+				// when
+				err := fragment.ConfigureHttpLog(given.config)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(given.config).To(Equal(given.expected))
+			},
+			Entry("none", testCase{
+				header:    "",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{},
+			}),
+			Entry("header", testCase{
+				header:    "content-type",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"content-type"},
+				},
+			}),
+			Entry("altHeader", testCase{
+				header:    "",
+				altHeader: "server",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"server"},
+				},
+			}),
+			Entry("header and altHeader", testCase{
+				header:    "content-type",
+				altHeader: "server",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"content-type", "server"},
+				},
+			}),
+			Entry("none w/ initial config", testCase{
+				header:    "",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires"},
+				},
+			}),
+			Entry("header w/ initial config", testCase{
+				header:    "content-type",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires", "content-type"},
+				},
+			}),
+			Entry("altHeader w/ initial config", testCase{
+				header:    "",
+				altHeader: "server",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires", "server"},
+				},
+			}),
+			Entry("header and altHeader w/ initial config", testCase{
+				header:    "content-type",
+				altHeader: "server",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseHeadersToLog: []string{"cache-control", "expires", "content-type", "server"},
+				},
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			expected  string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseHeaderOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%RESP()%", testCase{
+				expected: `%RESP()%`,
+			}),
+			Entry("%RESP():10%", testCase{
+				maxLength: 10,
+				expected:  `%RESP():10%`,
+			}),
+			Entry("%RESP(content-type)%", testCase{
+				header:   `content-type`,
+				expected: `%RESP(content-type)%`,
+			}),
+			Entry("%RESP(content-type):10%", testCase{
+				header:    `content-type`,
+				maxLength: 10,
+				expected:  `%RESP(content-type):10%`,
+			}),
+			Entry("%RESP(?server)%", testCase{
+				altHeader: `server`,
+				expected:  `%RESP(?server)%`,
+			}),
+			Entry("%RESP(?server):10%", testCase{
+				altHeader: `server`,
+				maxLength: 10,
+				expected:  `%RESP(?server):10%`,
+			}),
+			Entry("%RESP(content-type?server)%", testCase{
+				header:    "content-type",
+				altHeader: `server`,
+				expected:  `%RESP(content-type?server)%`,
+			}),
+			Entry("%RESP(content-type?server):10%", testCase{
+				header:    "content-type",
+				altHeader: `server`,
+				maxLength: 10,
+				expected:  `%RESP(content-type?server):10%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/response_trailer_operator_test.go b/pkg/envoy/accesslog/v3/response_trailer_operator_test.go
new file mode 100644
index 000000000..745c048b6
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/response_trailer_operator_test.go
@@ -0,0 +1,257 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+	accesslog_config "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
+)
+
+var _ = Describe("ResponseTrailerOperator", func() {
+
+	Describe("FormatHttpLogEntry()", func() {
+		example := &accesslog_data.HTTPAccessLogEntry{
+			Response: &accesslog_data.HTTPResponseProperties{
+				ResponseTrailers: map[string]string{
+					"grpc-status":  "14",
+					"grpc-message": "unavailable",
+				},
+			},
+		}
+
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			entry     *accesslog_data.HTTPAccessLogEntry
+			expected  string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseTrailerOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+				// when
+				actual, err := fragment.FormatHttpLogEntry(given.entry)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("no header or altHeader", testCase{
+				header:    "",
+				altHeader: "",
+				entry:     example,
+				expected:  ``, // apparently, Envoy allows both `Header` and `AltHeader` to be empty
+			}),
+			Entry("grpc-message", testCase{
+				header:   "grpc-message",
+				entry:    example,
+				expected: `unavailable`,
+			}),
+			Entry("x-trailer", testCase{
+				header:   "x-trailer", // missing header
+				entry:    example,
+				expected: ``,
+			}),
+			Entry("x-trailer or grpc-message", testCase{
+				header:    "x-trailer", // missing header
+				altHeader: "grpc-message",
+				entry:     example,
+				expected:  `unavailable`,
+			}),
+			Entry("x-trailer or grpc-message w/ MaxLength", testCase{
+				header:    "x-trailer", // missing header
+				altHeader: "grpc-message",
+				maxLength: 2,
+				entry:     example,
+				expected:  `un`,
+			}),
+			Entry("grpc-status or grpc-message", testCase{
+				header:    "grpc-status",
+				altHeader: "grpc-message",
+				entry:     example,
+				expected:  `14`,
+			}),
+			Entry("grpc-status or grpc-message", testCase{
+				header:    "grpc-status",
+				altHeader: "grpc-message",
+				maxLength: 1,
+				entry:     example,
+				expected:  `1`,
+			}),
+		)
+	})
+
+	Describe("FormatTcpLogEntry()", func() {
+		It("should always return an empty string", func() {
+			// setup
+			fragment := &ResponseTrailerOperator{HeaderFormatter{
+				Header: "grpc-status", AltHeader: "grpc-message", MaxLength: 123}}
+			// when
+			actual, err := fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{})
+			// then
+			Expect(err).ToNot(HaveOccurred())
+			// and
+			Expect(actual).To(Equal(``))
+		})
+	})
+
+	Describe("ConfigureHttpLog()", func() {
+
+		type testCase struct {
+			header    string
+			altHeader string
+			config    *accesslog_config.HttpGrpcAccessLogConfig
+			expected  *accesslog_config.HttpGrpcAccessLogConfig // verify the entire config to make sure there are no unexpected changes
+		}
+
+		DescribeTable("should configure properly",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseTrailerOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader}}
+				// when
+				err := fragment.ConfigureHttpLog(given.config)
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(given.config).To(Equal(given.expected))
+			},
+			Entry("none", testCase{
+				header:    "",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected:  &accesslog_config.HttpGrpcAccessLogConfig{},
+			}),
+			Entry("header", testCase{
+				header:    "grpc-status",
+				altHeader: "",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"grpc-status"},
+				},
+			}),
+			Entry("altHeader", testCase{
+				header:    "",
+				altHeader: "grpc-message",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"grpc-message"},
+				},
+			}),
+			Entry("header and altHeader", testCase{
+				header:    "grpc-status",
+				altHeader: "grpc-message",
+				config:    &accesslog_config.HttpGrpcAccessLogConfig{},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"grpc-status", "grpc-message"},
+				},
+			}),
+			Entry("none w/ initial config", testCase{
+				header:    "",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye"},
+				},
+			}),
+			Entry("header w/ initial config", testCase{
+				header:    "grpc-status",
+				altHeader: "",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye", "grpc-status"},
+				},
+			}),
+			Entry("altHeader w/ initial config", testCase{
+				header:    "",
+				altHeader: "grpc-message",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye", "grpc-message"},
+				},
+			}),
+			Entry("header and altHeader w/ initial config", testCase{
+				header:    "grpc-status",
+				altHeader: "grpc-message",
+				config: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye"},
+				},
+				expected: &accesslog_config.HttpGrpcAccessLogConfig{
+					AdditionalResponseTrailersToLog: []string{"x-trailer", "x-bye", "grpc-status", "grpc-message"},
+				},
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			header    string
+			altHeader string
+			maxLength int
+			expected  string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := &ResponseTrailerOperator{HeaderFormatter{
+					Header: given.header, AltHeader: given.altHeader, MaxLength: given.maxLength}}
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%TRAILER()%", testCase{
+				expected: `%TRAILER()%`,
+			}),
+			Entry("%TRAILER():10%", testCase{
+				maxLength: 10,
+				expected:  `%TRAILER():10%`,
+			}),
+			Entry("%TRAILER(grpc-status)%", testCase{
+				header:   `grpc-status`,
+				expected: `%TRAILER(grpc-status)%`,
+			}),
+			Entry("%TRAILER(grpc-status):10%", testCase{
+				header:    `grpc-status`,
+				maxLength: 10,
+				expected:  `%TRAILER(grpc-status):10%`,
+			}),
+			Entry("%TRAILER(?grpc-message)%", testCase{
+				altHeader: `grpc-message`,
+				expected:  `%TRAILER(?grpc-message)%`,
+			}),
+			Entry("%TRAILER(?grpc-message):10%", testCase{
+				altHeader: `grpc-message`,
+				maxLength: 10,
+				expected:  `%TRAILER(?grpc-message):10%`,
+			}),
+			Entry("%TRAILER(grpc-status?grpc-message)%", testCase{
+				header:    "grpc-status",
+				altHeader: `grpc-message`,
+				expected:  `%TRAILER(grpc-status?grpc-message)%`,
+			}),
+			Entry("%TRAILER(grpc-status?grpc-message):10%", testCase{
+				header:    "grpc-status",
+				altHeader: `grpc-message`,
+				maxLength: 10,
+				expected:  `%TRAILER(grpc-status?grpc-message):10%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/start_time_operator_test.go b/pkg/envoy/accesslog/v3/start_time_operator_test.go
new file mode 100644
index 000000000..d0561d49d
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/start_time_operator_test.go
@@ -0,0 +1,130 @@
+package v3_test
+
+import (
+	"time"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	"github.com/golang/protobuf/ptypes/timestamp"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+
+	util_proto "github.com/kumahq/kuma/pkg/util/proto"
+)
+
+var _ = Describe("StartTimeOperator", func() {
+
+	Describe("FormatHttpLogEntry() and FormatTcpLogEntry()", func() {
+		type testCase struct {
+			timeFormat       string
+			commonProperties *accesslog_data.AccessLogCommon
+			expected         string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := StartTimeOperator(given.timeFormat)
+
+				// when
+				actual, err := fragment.FormatHttpLogEntry(&accesslog_data.HTTPAccessLogEntry{
+					CommonProperties: given.commonProperties,
+				})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+
+				// when
+				actual, err = fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{
+					CommonProperties: given.commonProperties,
+				})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("start time is `nil`", testCase{
+				timeFormat:       "", // default time format
+				commonProperties: nil,
+				expected:         ``,
+			}),
+			Entry("start time is valid", testCase{
+				timeFormat: "", // default time format
+				commonProperties: &accesslog_data.AccessLogCommon{
+					StartTime: util_proto.MustTimestampProto(time.Unix(1582062737, 987654321)),
+				},
+				expected: `2020-02-18T21:52:17.987Z`,
+			}),
+			Entry("user-defined time format", testCase{
+				timeFormat: "%s.%3f", // not supported yet
+				commonProperties: &accesslog_data.AccessLogCommon{
+					StartTime: util_proto.MustTimestampProto(time.Unix(1582062737, 987654321)),
+				},
+				expected: `2020-02-18T21:52:17.987Z`,
+			}),
+		)
+
+		It("should fail if start time is not valid", func() {
+			// setup
+			fragment := StartTimeOperator("")
+
+			// given
+			commonProperties := &accesslog_data.AccessLogCommon{
+				StartTime: &timestamp.Timestamp{
+					Nanos: -1, // is considered invalid
+				},
+			}
+
+			// when
+			_, err := fragment.FormatHttpLogEntry(&accesslog_data.HTTPAccessLogEntry{
+				CommonProperties: commonProperties,
+			})
+			// then
+			Expect(err).To(HaveOccurred())
+
+			// when
+			_, err = fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{
+				CommonProperties: commonProperties,
+			})
+			// then
+			Expect(err).To(HaveOccurred())
+		})
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			timeFormat string
+			expected   string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := StartTimeOperator(given.timeFormat)
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("%START_TIME%", testCase{
+				timeFormat: "", // default time format
+				expected:   `%START_TIME%`,
+			}),
+			Entry("%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%", testCase{
+				timeFormat: "%Y/%m/%dT%H:%M:%S%z %s",
+				expected:   `%START_TIME(%Y/%m/%dT%H:%M:%S%z %s)%`,
+			}),
+			Entry("%START_TIME(%s.%3f)%", testCase{
+				timeFormat: "%s.%3f",
+				expected:   `%START_TIME(%s.%3f)%`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/text_span_fragment_test.go b/pkg/envoy/accesslog/v3/text_span_fragment_test.go
new file mode 100644
index 000000000..2b472809b
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/text_span_fragment_test.go
@@ -0,0 +1,78 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+
+	accesslog_data "github.com/envoyproxy/go-control-plane/envoy/data/accesslog/v3"
+)
+
+var _ = Describe("TextSpan", func() {
+
+	Describe("FormatHttpLogEntry() and FormatTcpLogEntry()", func() {
+		type testCase struct {
+			text     string
+			expected string
+		}
+
+		DescribeTable("should format properly",
+			func(given testCase) {
+				// setup
+				fragment := TextSpan(given.text)
+
+				// when
+				actual, err := fragment.FormatHttpLogEntry(&accesslog_data.HTTPAccessLogEntry{})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+
+				// when
+				actual, err = fragment.FormatTcpLogEntry(&accesslog_data.TCPAccessLogEntry{})
+				// then
+				Expect(err).ToNot(HaveOccurred())
+				// and
+				Expect(actual).To(Equal(given.expected))
+			},
+			Entry("", testCase{
+				text:     "",
+				expected: ``,
+			}),
+			Entry("plain text", testCase{
+				text:     "plain text",
+				expected: `plain text`,
+			}),
+		)
+	})
+
+	Describe("String()", func() {
+		type testCase struct {
+			text     string
+			expected string
+		}
+
+		DescribeTable("should return correct canonical representation",
+			func(given testCase) {
+				// setup
+				fragment := TextSpan(given.text)
+
+				// when
+				actual := fragment.String()
+				// then
+				Expect(actual).To(Equal(given.expected))
+
+			},
+			Entry("", testCase{
+				text:     "",
+				expected: ``,
+			}),
+			Entry("plain text", testCase{
+				text:     "plain text",
+				expected: `plain text`,
+			}),
+		)
+	})
+})
diff --git a/pkg/envoy/accesslog/v3/tls_cipher_suites_test.go b/pkg/envoy/accesslog/v3/tls_cipher_suites_test.go
new file mode 100644
index 000000000..f128561b4
--- /dev/null
+++ b/pkg/envoy/accesslog/v3/tls_cipher_suites_test.go
@@ -0,0 +1,149 @@
+package v3_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+
+	. "github.com/kumahq/kuma/pkg/envoy/accesslog/v3"
+)
+
+var _ = Describe("TlsCipherSuite", func() {
+
+	Describe("String()", func() {
+		type testCase struct {
+			cipherID TlsCipherSuite
+			expected string
+		}
+
+		DescribeTable("should return an Envoy-compatible TLS Cipher Suite name",
+			func(given testCase) {
+				Expect(given.cipherID.String()).To(Equal(given.expected))
+			},
+			Entry("TLS_RSA_WITH_RC4_128_SHA", testCase{
+				cipherID: TLS_RSA_WITH_RC4_128_SHA,
+				expected: `RSA-RC4-128-SHA`,
+			}),
+			Entry("TLS_RSA_WITH_3DES_EDE_CBC_SHA", testCase{
+				cipherID: TLS_RSA_WITH_3DES_EDE_CBC_SHA,
+				expected: `RSA-3DES-EDE-CBC-SHA`,
+			}),
+			Entry("TLS_RSA_WITH_AES_128_CBC_SHA", testCase{
+				cipherID: TLS_RSA_WITH_AES_128_CBC_SHA,
+				expected: `RSA-AES-128-CBC-SHA`,
+			}),
+			Entry("TLS_RSA_WITH_AES_256_CBC_SHA", testCase{
+				cipherID: TLS_RSA_WITH_AES_256_CBC_SHA,
+				expected: `RSA-AES-256-CBC-SHA`,
+			}),
+			Entry("TLS_RSA_WITH_AES_128_CBC_SHA256", testCase{
+				cipherID: TLS_RSA_WITH_AES_128_CBC_SHA256,
+				expected: `RSA-AES-128-CBC-SHA256`,
+			}),
+			Entry("TLS_RSA_WITH_AES_128_GCM_SHA256", testCase{
+				cipherID: TLS_RSA_WITH_AES_128_GCM_SHA256,
+				expected: `RSA-AES-128-GCM-SHA256`,
+			}),
+			Entry("TLS_RSA_WITH_AES_256_GCM_SHA384", testCase{
+				cipherID: TLS_RSA_WITH_AES_256_GCM_SHA384,
+				expected: `RSA-AES-256-GCM-SHA384`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,
+				expected: `ECDHE-ECDSA-RC4-128-SHA`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
+				expected: `ECDHE-ECDSA-AES-128-CBC-SHA`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
+				expected: `ECDHE-ECDSA-AES-256-CBC-SHA`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_RC4_128_SHA", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_RC4_128_SHA,
+				expected: `ECDHE-RSA-RC4-128-SHA`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
+				expected: `ECDHE-RSA-3DES-EDE-CBC-SHA`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
+				expected: `ECDHE-RSA-AES-128-CBC-SHA`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
+				expected: `ECDHE-RSA-AES-256-CBC-SHA`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
+				expected: `ECDHE-ECDSA-AES-128-CBC-SHA256`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
+				expected: `ECDHE-RSA-AES-128-CBC-SHA256`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+				expected: `ECDHE-RSA-AES-128-GCM-SHA256`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+				expected: `ECDHE-RSA-AES-128-GCM-SHA256`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+				expected: `ECDHE-ECDSA-AES-128-GCM-SHA256`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+				expected: `ECDHE-RSA-AES-256-GCM-SHA384`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+				expected: `ECDHE-ECDSA-AES-256-GCM-SHA384`,
+			}),
+			Entry("TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305", testCase{
+				cipherID: TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+				expected: `ECDHE-RSA-CHACHA20-POLY1305`,
+			}),
+			Entry("TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305", testCase{
+				cipherID: TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+				expected: `ECDHE-ECDSA-CHACHA20-POLY1305`,
+			}),
+			Entry("TLS_AES_128_GCM_SHA256", testCase{
+				cipherID: TLS_AES_128_GCM_SHA256,
+				expected: `AES-128-GCM-SHA256`,
+			}),
+			Entry("TLS_AES_256_GCM_SHA384", testCase{
+				cipherID: TLS_AES_256_GCM_SHA384,
+				expected: `AES-256-GCM-SHA384`,
+			}),
+			Entry("TLS_CHACHA20_POLY1305_SHA256", testCase{
+				cipherID: TLS_CHACHA20_POLY1305_SHA256,
+				expected: `CHACHA20-POLY1305-SHA256`,
+			}),
+			Entry("TLS_FALLBACK_SCSV", testCase{
+				cipherID: TLS_FALLBACK_SCSV,
+				expected: `FALLBACK-SCSV`,
+			}),
+			Entry("TLS_RSA_WITH_NULL_MD5", testCase{
+				cipherID: TlsCipherSuite(0x01),
+				expected: `0x1`,
+			}),
+			Entry("TLS_RSA_WITH_NULL_SHA", testCase{
+				cipherID: TlsCipherSuite(0x02),
+				expected: `0x2`,
+			}),
+			Entry("TLS_RSA_EXPORT_WITH_RC4_40_MD5", testCase{
+				cipherID: TlsCipherSuite(0x03),
+				expected: `0x3`,
+			}),
+			Entry("TLS_KRB5_WITH_3DES_EDE_CBC_SHA", testCase{
+				cipherID: TlsCipherSuite(0x1F),
+				expected: `0x1f`,
+			}),
+		)
+	})
+})
diff --git a/pkg/insights/resyncer_test.go b/pkg/insights/resyncer_test.go
index beacedf10..bf15665ca 100644
--- a/pkg/insights/resyncer_test.go
+++ b/pkg/insights/resyncer_test.go
@@ -59,7 +59,11 @@ var _ = Describe("Insight Persistence", func() {
 
 	BeforeEach(func() {
 		rm = manager.NewResourceManager(memory.NewStore())
+
+		nowMtx.Lock()
 		now = time.Now()
+		nowMtx.Unlock()
+
 		eventCh = make(chan events.Event)
 		stopCh = make(chan struct{})
 
@@ -79,10 +83,10 @@ var _ = Describe("Insight Persistence", func() {
 				return tickCh
 			},
 		})
-		go func() {
+		go func(stopCh chan struct{}) {
 			err := resyncer.Start(stopCh)
 			Expect(err).ToNot(HaveOccurred())
-		}()
+		}(stopCh)
 	})
 
 	It("should sync more often than MaxResyncTimeout", func() {
diff --git a/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer_test.go b/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer_test.go
index 123091278..fbc6d18cb 100644
--- a/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer_test.go
+++ b/pkg/xds/envoy/listeners/v3/transparent_proxying_configurer_test.go
@@ -54,8 +54,7 @@ var _ = Describe("TransparentProxyingConfigurer", func() {
               socketAddress:
                 address: 192.168.0.1
                 portValue: 8080
-            deprecatedV1:
-              bindToPort: false
+            bindToPort: false
 `,
 		}),
 		Entry("basic listener without transparent proxying", testCase{
diff --git a/pkg/xds/server/v2/reconcile_test.go b/pkg/xds/server/v2/reconcile_test.go
index eb6977e6c..5b2b18753 100644
--- a/pkg/xds/server/v2/reconcile_test.go
+++ b/pkg/xds/server/v2/reconcile_test.go
@@ -51,28 +51,38 @@ var _ = Describe("Reconcile", func() {
 		snapshot := envoy_cache.Snapshot{
 			Resources: [envoy_types.UnknownType]envoy_cache.Resources{
 				envoy_types.Listener: {
-					Items: map[string]envoy_types.Resource{
-						"listener": &envoy.Listener{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"listener": {
+							Resource: &envoy.Listener{},
+						},
 					},
 				},
 				envoy_types.Route: {
-					Items: map[string]envoy_types.Resource{
-						"route": &envoy.RouteConfiguration{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"route": {
+							Resource: &envoy.RouteConfiguration{},
+						},
 					},
 				},
 				envoy_types.Cluster: {
-					Items: map[string]envoy_types.Resource{
-						"cluster": &envoy.Cluster{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"cluster": {
+							Resource: &envoy.Cluster{},
+						},
 					},
 				},
 				envoy_types.Endpoint: {
-					Items: map[string]envoy_types.Resource{
-						"endpoint": &envoy.ClusterLoadAssignment{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"endpoint": {
+							Resource: &envoy.ClusterLoadAssignment{},
+						},
 					},
 				},
 				envoy_types.Secret: {
-					Items: map[string]envoy_types.Resource{
-						"secret": &envoy_auth.Secret{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"secret": {
+							Resource: &envoy_auth.Secret{},
+						},
 					},
 				},
 			},
diff --git a/pkg/xds/server/v3/reconcile_test.go b/pkg/xds/server/v3/reconcile_test.go
index e762eb3ad..82b4f598a 100644
--- a/pkg/xds/server/v3/reconcile_test.go
+++ b/pkg/xds/server/v3/reconcile_test.go
@@ -50,28 +50,38 @@ var _ = Describe("Reconcile", func() {
 		snapshot := envoy_cache.Snapshot{
 			Resources: [envoy_types.UnknownType]envoy_cache.Resources{
 				envoy_types.Listener: {
-					Items: map[string]envoy_types.Resource{
-						"listener": &envoy.Listener{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"listener": {
+							Resource: &envoy.Listener{},
+						},
 					},
 				},
 				envoy_types.Route: {
-					Items: map[string]envoy_types.Resource{
-						"route": &envoy.RouteConfiguration{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"route": {
+							Resource: &envoy.RouteConfiguration{},
+						},
 					},
 				},
 				envoy_types.Cluster: {
-					Items: map[string]envoy_types.Resource{
-						"cluster": &envoy.Cluster{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"cluster": {
+							Resource: &envoy.Cluster{},
+						},
 					},
 				},
 				envoy_types.Endpoint: {
-					Items: map[string]envoy_types.Resource{
-						"endpoint": &envoy.ClusterLoadAssignment{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"endpoint": {
+							Resource: &envoy.ClusterLoadAssignment{},
+						},
 					},
 				},
 				envoy_types.Secret: {
-					Items: map[string]envoy_types.Resource{
-						"secret": &envoy_auth.Secret{},
+					Items: map[string]envoy_types.ResourceWithTtl{
+						"secret": {
+							Resource: &envoy_auth.Secret{},
+						},
 					},
 				},
 			},
diff --git a/pkg/xds/server/v3/testdata/envoy-config.golden.yaml b/pkg/xds/server/v3/testdata/envoy-config.golden.yaml
index 33c453412..f174ad8a1 100644
--- a/pkg/xds/server/v3/testdata/envoy-config.golden.yaml
+++ b/pkg/xds/server/v3/testdata/envoy-config.golden.yaml
@@ -58,8 +58,7 @@ resources:
         socketAddress:
           address: 192.168.0.1
           portValue: 443
-      deprecatedV1:
-        bindToPort: false
+      bindToPort: false
       filterChains:
         - filters:
             - name: envoy.filters.network.rbac
@@ -121,8 +120,7 @@ resources:
         socketAddress:
           address: 192.168.0.1
           portValue: 80
-      deprecatedV1:
-        bindToPort: false
+      bindToPort: false
       filterChains:
         - filters:
             - name: envoy.filters.network.rbac
@@ -192,8 +190,7 @@ resources:
         socketAddress:
           address: 192.168.0.2
           portValue: 443
-      deprecatedV1:
-        bindToPort: false
+      bindToPort: false
       filterChains:
         - filters:
             - name: envoy.filters.network.rbac
@@ -255,8 +252,7 @@ resources:
         socketAddress:
           address: 192.168.0.2
           portValue: 80
-      deprecatedV1:
-        bindToPort: false
+      bindToPort: false
       filterChains:
         - filters:
             - name: envoy.filters.network.rbac
@@ -327,6 +323,7 @@ resources:
                 statPrefix: inbound_passthrough
       name: inbound:passthrough
       trafficDirection: INBOUND
+      useOriginalDst: true
   - name: outbound:passthrough
     resource:
       '@type': type.googleapis.com/envoy.config.listener.v3.Listener
@@ -343,3 +340,4 @@ resources:
                 statPrefix: outbound_passthrough
       name: outbound:passthrough
       trafficDirection: OUTBOUND
+      useOriginalDst: true