Repository: envoyproxy/gateway
Pull request #3688 (2024-06-27 09:17:14)

feat: AccessLog support CEL Filter
xref: #3667

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/envoyproxy/gateway/pull/3688?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) Report
Attention: Patch coverage is `97.50000%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 68.58%. Comparing base [(`22984d7`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/22984d79da1ebd1dece47838ed5a964bc6335ede?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) to head [(`f752edc`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/f752edcebaf8302277b3e76fbfedf4ef7c94c2f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/envoyproxy/gateway/pull/3688?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) | Patch % | Lines |
|---|---|---|
| [internal/gatewayapi/runner/runner.go](https://app.codecov.io/gh/envoyproxy/gateway/pull/3688?src=pr&el=tree&filepath=internal%2Fgatewayapi%2Frunner%2Frunner.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy#diff-aW50ZXJuYWwvZ2F0ZXdheWFwaS9ydW5uZXIvcnVubmVyLmdv) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/envoyproxy/gateway/pull/3688?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3688      +/-   ##
==========================================
+ Coverage   68.33%   68.58%   +0.25%     
==========================================
  Files         170      170              
  Lines       20780    20728      -52     
==========================================
+ Hits        14199    14217      +18     
+ Misses       5563     5495      -68     
+ Partials     1018     1016       -2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/envoyproxy/gateway/pull/3688?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).


Patch:
diff --git a/go.mod b/go.mod
index 9d4f56f40..b65d90750 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	github.com/go-logr/zapr v1.3.0
 	github.com/gogo/protobuf v1.3.2
 	github.com/golang/protobuf v1.5.4
+	github.com/google/cel-go v0.20.1
 	github.com/google/go-cmp v0.6.0
 	github.com/google/go-containerregistry v0.19.1
 	github.com/grafana/tempo v1.5.0
@@ -78,6 +79,7 @@ require (
 	github.com/Masterminds/squirrel v1.5.4 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/Microsoft/hcsshim v0.12.3 // indirect
+	github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
 	github.com/blang/semver/v4 v4.0.0 // indirect
 	github.com/containerd/containerd v1.7.17 // indirect
@@ -120,6 +122,7 @@ require (
 	github.com/rubenv/sql-migrate v1.6.1 // indirect
 	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/spf13/cast v1.6.0 // indirect
+	github.com/stoewer/go-strcase v1.2.0 // indirect
 	github.com/vbatts/tar-split v0.11.5 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
diff --git a/go.sum b/go.sum
index 83196a0ef..8bc7d41c3 100644
--- a/go.sum
+++ b/go.sum
@@ -67,6 +67,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alessio/shellescape v1.2.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
+github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
+github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
@@ -322,6 +324,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
 github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/cel-go v0.20.1 h1:nDx9r8S3L4pE61eDdt8igGj8rf5kjYR3ILxWIpWNi84=
+github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u4dOYLg=
 github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
 github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -624,6 +628,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
diff --git a/internal/gatewayapi/listener.go b/internal/gatewayapi/listener.go
index 6978d806a..162a9eca9 100644
--- a/internal/gatewayapi/listener.go
+++ b/internal/gatewayapi/listener.go
@@ -9,8 +9,10 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/google/cel-go/cel"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
 	"k8s.io/utils/ptr"
 	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
 
@@ -301,6 +303,22 @@ func (t *Translator) processAccessLog(envoyproxy *egv1a1.EnvoyProxy, resources *
 				irAccessLog.OpenTelemetry = append(irAccessLog.OpenTelemetry, al)
 			}
 		}
+
+		var (
+			validExprs []string
+			errs       []error
+		)
+		for _, expr := range accessLog.Matches {
+			if !validCELExpression(expr) {
+				errs = append(errs, fmt.Errorf("invalid CEL expression: %s", expr))
+				continue
+			}
+			validExprs = append(validExprs, expr)
+		}
+		if len(errs) > 0 {
+			return nil, utilerrors.NewAggregate(errs)
+		}
+		irAccessLog.CELMatches = validExprs
 	}
 
 	return irAccessLog, nil
@@ -411,3 +429,10 @@ func destinationSettingFromHostAndPort(host string, port uint32) []*ir.Destinati
 		},
 	}
 }
+
+var celEnv, _ = cel.NewEnv()
+
+func validCELExpression(expr string) bool {
+	_, issue := celEnv.Parse(expr)
+	return issue.Err() == nil
+}
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index b3d3ed3ef..92e6f748e 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -1599,6 +1599,7 @@ type RateLimitValue struct {
 // AccessLog holds the access logging configuration.
 // +k8s:deepcopy-gen=true
 type AccessLog struct {
+	CELMatches    []string                  `json:"celMatches,omitempty" yaml:"celMatches,omitempty"`
 	Text          []*TextAccessLog          `json:"text,omitempty" yaml:"text,omitempty"`
 	JSON          []*JSONAccessLog          `json:"json,omitempty" yaml:"json,omitempty"`
 	OpenTelemetry []*OpenTelemetryAccessLog `json:"openTelemetry,omitempty" yaml:"openTelemetry,omitempty"`
diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go
index 4b87aa93b..e98364722 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -19,6 +19,11 @@ import (
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *AccessLog) DeepCopyInto(out *AccessLog) {
 	*out = *in
+	if in.CELMatches != nil {
+		in, out := &in.CELMatches, &out.CELMatches
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 	if in.Text != nil {
 		in, out := &in.Text, &out.Text
 		*out = make([]*TextAccessLog, len(*in))
diff --git a/internal/xds/translator/accesslog.go b/internal/xds/translator/accesslog.go
index ce58b507c..05e05bee3 100644
--- a/internal/xds/translator/accesslog.go
+++ b/internal/xds/translator/accesslog.go
@@ -13,6 +13,7 @@ import (
 	accesslog "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
 	cfgcore "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	fileaccesslog "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/file/v3"
+	cel "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/filters/cel/v3"
 	grpcaccesslog "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
 	otelaccesslog "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/open_telemetry/v3"
 	celformatter "github.com/envoyproxy/go-control-plane/envoy/extensions/formatter/cel/v3"
@@ -54,6 +55,8 @@ const (
 	reqWithoutQueryCommandOperator = "%REQ_WITHOUT_QUERY"
 	metadataCommandOperator        = "%METADATA"
 	celCommandOperator             = "%CEL"
+
+	celFilter = "envoy.access_loggers.extension_filters.cel"
 )
 
 // for the case when a route does not exist to upstream, hcm logs will not be present
@@ -217,16 +220,57 @@ func buildXdsAccessLog(al *ir.AccessLog, forListener bool) []*accesslog.AccessLo
 		})
 	}
 
-	// add filter for listener access logs
+	// add filter for access logs
+	filters := make([]*accesslog.AccessLogFilter, 0)
+	for _, expr := range al.CELMatches {
+		filters = append(filters, celAccessLogFilter(expr))
+	}
 	if forListener {
-		for _, al := range accessLogs {
-			al.Filter = listenerAccessLogFilter
-		}
+		filters = append(filters, listenerAccessLogFilter)
+	}
+
+	f := buildAccessLogFilter(filters...)
+
+	for _, log := range accessLogs {
+		log.Filter = f
 	}
 
 	return accessLogs
 }
 
+func celAccessLogFilter(expr string) *accesslog.AccessLogFilter {
+	fl := &cel.ExpressionFilter{
+		Expression: expr,
+	}
+
+	return &accesslog.AccessLogFilter{
+		FilterSpecifier: &accesslog.AccessLogFilter_ExtensionFilter{
+			ExtensionFilter: &accesslog.ExtensionFilter{
+				Name:       celFilter,
+				ConfigType: &accesslog.ExtensionFilter_TypedConfig{TypedConfig: protocov.ToAny(fl)},
+			},
+		},
+	}
+}
+
+func buildAccessLogFilter(f ...*accesslog.AccessLogFilter) *accesslog.AccessLogFilter {
+	if len(f) == 0 {
+		return nil
+	}
+
+	if len(f) == 1 {
+		return f[0]
+	}
+
+	return &accesslog.AccessLogFilter{
+		FilterSpecifier: &accesslog.AccessLogFilter_AndFilter{
+			AndFilter: &accesslog.AndFilter{
+				Filters: f,
+			},
+		},
+	}
+}
+
 func accessLogTextFormatters(text string) []*cfgcore.TypedExtensionConfig {
 	formatters := make([]*cfgcore.TypedExtensionConfig, 0, 3)
 


Test patch:
diff --git a/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.in.yaml b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.in.yaml
new file mode 100644
index 000000000..dd96ed4b0
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.in.yaml
@@ -0,0 +1,90 @@
+envoyProxyForGatewayClass:
+  apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyProxy
+  metadata:
+    namespace: envoy-gateway-system
+    name: test
+  spec:
+    telemetry:
+      accessLog:
+        settings:
+        - matches:
+          - "response.code >= 400"
+          - ")++++" # invalid CEL expression will be ignored
+          format:
+            type: Text
+            text: |
+              [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+          sinks:
+          - type: File
+            file:
+              path: /dev/stdout
+          - type: OpenTelemetry
+            openTelemetry:
+              host: otel-collector.monitoring.svc.cluster.local
+              port: 4317
+              resources:
+                k8s.cluster.name: "cluster-1"
+    provider:
+      type: Kubernetes
+      kubernetes:
+        envoyService:
+          type: LoadBalancer
+        envoyDeployment:
+          replicas: 2
+          container:
+            env:
+            - name: env_a
+              value: env_a_value
+            - name: env_b
+              value: env_b_name
+            image: "envoyproxy/envoy:distroless-dev"
+            resources:
+              requests:
+                cpu: 100m
+                memory: 512Mi
+            securityContext:
+              runAsUser: 2000
+              allowPrivilegeEscalation: false
+          pod:
+            annotations:
+              key1: val1
+              key2: val2
+            affinity:
+              nodeAffinity:
+                requiredDuringSchedulingIgnoredDuringExecution:
+                  nodeSelectorTerms:
+                  - matchExpressions:
+                    - key: cloud.google.com/gke-nodepool
+                      operator: In
+                      values:
+                      - router-node
+            tolerations:
+            - effect: NoSchedule
+              key: node-type
+              operator: Exists
+              value: "router"
+            securityContext:
+              runAsUser: 1000
+              runAsGroup: 3000
+              fsGroup: 2000
+              fsGroupChangePolicy: "OnRootMismatch"
+            volumes:
+            - name: certs
+              secret:
+                secretName: envoy-cert
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: Same
diff --git a/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.out.yaml b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.out.yaml
new file mode 100644
index 000000000..e23a52460
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel-with-invalid.out.yaml
@@ -0,0 +1,153 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    conditions:
+    - lastTransitionTime: null
+      message: 'Invalid access log backendRefs: invalid CEL expression: )++++'
+      reason: Invalid
+      status: "False"
+      type: ListenersNotValid
+    listeners:
+    - attachedRoutes: 0
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      config:
+        apiVersion: gateway.envoyproxy.io/v1alpha1
+        kind: EnvoyProxy
+        metadata:
+          creationTimestamp: null
+          name: test
+          namespace: envoy-gateway-system
+        spec:
+          logging: {}
+          provider:
+            kubernetes:
+              envoyDeployment:
+                container:
+                  env:
+                  - name: env_a
+                    value: env_a_value
+                  - name: env_b
+                    value: env_b_name
+                  image: envoyproxy/envoy:distroless-dev
+                  resources:
+                    requests:
+                      cpu: 100m
+                      memory: 512Mi
+                  securityContext:
+                    allowPrivilegeEscalation: false
+                    runAsUser: 2000
+                pod:
+                  affinity:
+                    nodeAffinity:
+                      requiredDuringSchedulingIgnoredDuringExecution:
+                        nodeSelectorTerms:
+                        - matchExpressions:
+                          - key: cloud.google.com/gke-nodepool
+                            operator: In
+                            values:
+                            - router-node
+                  annotations:
+                    key1: val1
+                    key2: val2
+                  securityContext:
+                    fsGroup: 2000
+                    fsGroupChangePolicy: OnRootMismatch
+                    runAsGroup: 3000
+                    runAsUser: 1000
+                  tolerations:
+                  - effect: NoSchedule
+                    key: node-type
+                    operator: Exists
+                    value: router
+                  volumes:
+                  - name: certs
+                    secret:
+                      secretName: envoy-cert
+                replicas: 2
+              envoyService:
+                type: LoadBalancer
+            type: Kubernetes
+          telemetry:
+            accessLog:
+              settings:
+              - format:
+                  text: |
+                    [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+                  type: Text
+                matches:
+                - response.code >= 400
+                - )++++
+                sinks:
+                - file:
+                    path: /dev/stdout
+                  type: File
+                - openTelemetry:
+                    host: otel-collector.monitoring.svc.cluster.local
+                    port: 4317
+                    resources:
+                      k8s.cluster.name: cluster-1
+                  type: OpenTelemetry
+        status: {}
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: http-80
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+xdsIR:
+  envoy-gateway/gateway-1:
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
diff --git a/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.in.yaml b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.in.yaml
new file mode 100644
index 000000000..3d9f52018
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.in.yaml
@@ -0,0 +1,89 @@
+envoyProxyForGatewayClass:
+  apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyProxy
+  metadata:
+    namespace: envoy-gateway-system
+    name: test
+  spec:
+    telemetry:
+      accessLog:
+        settings:
+        - matches:
+          - "response.code >= 400"
+          format:
+            type: Text
+            text: |
+              [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+          sinks:
+          - type: File
+            file:
+              path: /dev/stdout
+          - type: OpenTelemetry
+            openTelemetry:
+              host: otel-collector.monitoring.svc.cluster.local
+              port: 4317
+              resources:
+                k8s.cluster.name: "cluster-1"
+    provider:
+      type: Kubernetes
+      kubernetes:
+        envoyService:
+          type: LoadBalancer
+        envoyDeployment:
+          replicas: 2
+          container:
+            env:
+            - name: env_a
+              value: env_a_value
+            - name: env_b
+              value: env_b_name
+            image: "envoyproxy/envoy:distroless-dev"
+            resources:
+              requests:
+                cpu: 100m
+                memory: 512Mi
+            securityContext:
+              runAsUser: 2000
+              allowPrivilegeEscalation: false
+          pod:
+            annotations:
+              key1: val1
+              key2: val2
+            affinity:
+              nodeAffinity:
+                requiredDuringSchedulingIgnoredDuringExecution:
+                  nodeSelectorTerms:
+                  - matchExpressions:
+                    - key: cloud.google.com/gke-nodepool
+                      operator: In
+                      values:
+                      - router-node
+            tolerations:
+            - effect: NoSchedule
+              key: node-type
+              operator: Exists
+              value: "router"
+            securityContext:
+              runAsUser: 1000
+              runAsGroup: 3000
+              fsGroup: 2000
+              fsGroupChangePolicy: "OnRootMismatch"
+            volumes:
+            - name: certs
+              secret:
+                secretName: envoy-cert
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: Same
diff --git a/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.out.yaml b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.out.yaml
new file mode 100644
index 000000000..3d0c0ae52
--- /dev/null
+++ b/internal/gatewayapi/testdata/envoyproxy-accesslog-cel.out.yaml
@@ -0,0 +1,167 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 0
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      config:
+        apiVersion: gateway.envoyproxy.io/v1alpha1
+        kind: EnvoyProxy
+        metadata:
+          creationTimestamp: null
+          name: test
+          namespace: envoy-gateway-system
+        spec:
+          logging: {}
+          provider:
+            kubernetes:
+              envoyDeployment:
+                container:
+                  env:
+                  - name: env_a
+                    value: env_a_value
+                  - name: env_b
+                    value: env_b_name
+                  image: envoyproxy/envoy:distroless-dev
+                  resources:
+                    requests:
+                      cpu: 100m
+                      memory: 512Mi
+                  securityContext:
+                    allowPrivilegeEscalation: false
+                    runAsUser: 2000
+                pod:
+                  affinity:
+                    nodeAffinity:
+                      requiredDuringSchedulingIgnoredDuringExecution:
+                        nodeSelectorTerms:
+                        - matchExpressions:
+                          - key: cloud.google.com/gke-nodepool
+                            operator: In
+                            values:
+                            - router-node
+                  annotations:
+                    key1: val1
+                    key2: val2
+                  securityContext:
+                    fsGroup: 2000
+                    fsGroupChangePolicy: OnRootMismatch
+                    runAsGroup: 3000
+                    runAsUser: 1000
+                  tolerations:
+                  - effect: NoSchedule
+                    key: node-type
+                    operator: Exists
+                    value: router
+                  volumes:
+                  - name: certs
+                    secret:
+                      secretName: envoy-cert
+                replicas: 2
+              envoyService:
+                type: LoadBalancer
+            type: Kubernetes
+          telemetry:
+            accessLog:
+              settings:
+              - format:
+                  text: |
+                    [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+                  type: Text
+                matches:
+                - response.code >= 400
+                sinks:
+                - file:
+                    path: /dev/stdout
+                  type: File
+                - openTelemetry:
+                    host: otel-collector.monitoring.svc.cluster.local
+                    port: 4317
+                    resources:
+                      k8s.cluster.name: cluster-1
+                  type: OpenTelemetry
+        status: {}
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: http-80
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+xdsIR:
+  envoy-gateway/gateway-1:
+    accessLog:
+      celMatches:
+      - response.code >= 400
+      openTelemetry:
+      - authority: otel-collector.monitoring.svc.cluster.local
+        destination:
+          name: accesslog-0
+          settings:
+          - endpoints:
+            - host: otel-collector.monitoring.svc.cluster.local
+              port: 4317
+            protocol: GRPC
+            weight: 1
+        resources:
+          k8s.cluster.name: cluster-1
+        text: |
+          [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+      text:
+      - format: |
+          [%START_TIME%] "%REQ(:METHOD)% %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)%" "%UPSTREAM_HOST%"\n
+        path: /dev/stdout
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
diff --git a/internal/xds/translator/testdata/in/xds-ir/accesslog-cel.yaml b/internal/xds/translator/testdata/in/xds-ir/accesslog-cel.yaml
new file mode 100644
index 000000000..b0623fd08
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/accesslog-cel.yaml
@@ -0,0 +1,52 @@
+name: "accesslog"
+accesslog:
+  celMatches:
+  - response.code >= 400
+  text:
+  - path: "/dev/stdout"
+    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)%" "%UPSTREAM_HOST%"
+  json:
+  - path: "/dev/stdout"
+    json:
+      start_time: "%START_TIME%"
+      method: "%REQ(:METHOD)%"
+      path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
+      protocol: "%PROTOCOL%"
+      response_code: "%RESPONSE_CODE%"
+  openTelemetry:
+  - text: |
+      [%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)%" "%UPSTREAM_HOST%"
+    attributes:
+      "response_code": "%RESPONSE_CODE%"
+    resources:
+      "cluster_name": "cluster1"
+    authority: "otel-collector.default.svc.cluster.local"
+    destination:
+      name: "accesslog-0"
+      settings:
+      - endpoints:
+        - host: "otel-collector.default.svc.cluster.local"
+          port: 4317
+        protocol: "GRPC"
+http:
+- name: "first-listener"
+  address: "0.0.0.0"
+  port: 10080
+  hostnames:
+  - "*"
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  routes:
+  - name: "direct-route"
+    hostname: "*"
+    destination:
+      name: "direct-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
+    directResponse:
+      body: "Unknown custom filter type: UnsupportedType"
+      statusCode: 500
diff --git a/internal/xds/translator/testdata/in/xds-ir/accesslog-multi-cel.yaml b/internal/xds/translator/testdata/in/xds-ir/accesslog-multi-cel.yaml
new file mode 100644
index 000000000..704c19863
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/accesslog-multi-cel.yaml
@@ -0,0 +1,53 @@
+name: "accesslog"
+accesslog:
+  celMatches:
+  - response.code >= 400
+  - request.url_path.contains('v1beta3')
+  text:
+  - path: "/dev/stdout"
+    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)%" "%UPSTREAM_HOST%"
+  json:
+  - path: "/dev/stdout"
+    json:
+      start_time: "%START_TIME%"
+      method: "%REQ(:METHOD)%"
+      path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
+      protocol: "%PROTOCOL%"
+      response_code: "%RESPONSE_CODE%"
+  openTelemetry:
+  - text: |
+      [%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)%" "%UPSTREAM_HOST%"
+    attributes:
+      "response_code": "%RESPONSE_CODE%"
+    resources:
+      "cluster_name": "cluster1"
+    authority: "otel-collector.default.svc.cluster.local"
+    destination:
+      name: "accesslog-0"
+      settings:
+      - endpoints:
+        - host: "otel-collector.default.svc.cluster.local"
+          port: 4317
+        protocol: "GRPC"
+http:
+- name: "first-listener"
+  address: "0.0.0.0"
+  port: 10080
+  hostnames:
+  - "*"
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  routes:
+  - name: "direct-route"
+    hostname: "*"
+    destination:
+      name: "direct-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
+    directResponse:
+      body: "Unknown custom filter type: UnsupportedType"
+      statusCode: 500
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.clusters.yaml
new file mode 100644
index 000000000..b8874bf24
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.clusters.yaml
@@ -0,0 +1,49 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: direct-route-dest
+  lbPolicy: LEAST_REQUEST
+  name: direct-route-dest
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  dnsRefreshRate: 30s
+  lbPolicy: LEAST_REQUEST
+  loadAssignment:
+    clusterName: accesslog-0
+    endpoints:
+    - lbEndpoints:
+      - endpoint:
+          address:
+            socketAddress:
+              address: otel-collector.default.svc.cluster.local
+              portValue: 4317
+        loadBalancingWeight: 1
+      loadBalancingWeight: 1
+      locality:
+        region: accesslog-0/backend/0
+  name: accesslog-0
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  respectDnsTtl: true
+  type: STRICT_DNS
+  typedExtensionProtocolOptions:
+    envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
+      '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
+      explicitHttpConfig:
+        http2ProtocolOptions: {}
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.endpoints.yaml
new file mode 100644
index 000000000..20c80b3aa
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.endpoints.yaml
@@ -0,0 +1,12 @@
+- clusterName: direct-route-dest
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 1.2.3.4
+            portValue: 50000
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality:
+      region: direct-route-dest/backend/0
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.listeners.yaml
new file mode 100644
index 000000000..2ccfca8ce
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.listeners.yaml
@@ -0,0 +1,184 @@
+- accessLog:
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.file
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+      logFormat:
+        textFormatSource:
+          inlineString: |
+            [%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)%" "%UPSTREAM_HOST%"
+      path: /dev/stdout
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.file
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+      logFormat:
+        jsonFormat:
+          method: '%REQ(:METHOD)%'
+          path: '%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%'
+          protocol: '%PROTOCOL%'
+          response_code: '%RESPONSE_CODE%'
+          start_time: '%START_TIME%'
+      path: /dev/stdout
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.open_telemetry
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.open_telemetry.v3.OpenTelemetryAccessLogConfig
+      attributes:
+        values:
+        - key: k8s.namespace.name
+          value:
+            stringValue: '%ENVIRONMENT(ENVOY_GATEWAY_NAMESPACE)%'
+        - key: k8s.pod.name
+          value:
+            stringValue: '%ENVIRONMENT(ENVOY_POD_NAME)%'
+        - key: response_code
+          value:
+            stringValue: '%RESPONSE_CODE%'
+      body:
+        stringValue: |
+          [%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)%" "%UPSTREAM_HOST%"
+      commonConfig:
+        grpcService:
+          envoyGrpc:
+            authority: otel-collector.default.svc.cluster.local
+            clusterName: accesslog-0
+        logName: otel_envoy_accesslog
+        transportApiVersion: V3
+      resourceAttributes:
+        values:
+        - key: cluster_name
+          value:
+            stringValue: cluster1
+  address:
+    socketAddress:
+      address: 0.0.0.0
+      portValue: 10080
+  defaultFilterChain:
+    filters:
+    - name: envoy.filters.network.http_connection_manager
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+        accessLog:
+        - filter:
+            extensionFilter:
+              name: envoy.access_loggers.extension_filters.cel
+              typedConfig:
+                '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                expression: response.code >= 400
+          name: envoy.access_loggers.file
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+            logFormat:
+              textFormatSource:
+                inlineString: |
+                  [%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)%" "%UPSTREAM_HOST%"
+            path: /dev/stdout
+        - filter:
+            extensionFilter:
+              name: envoy.access_loggers.extension_filters.cel
+              typedConfig:
+                '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                expression: response.code >= 400
+          name: envoy.access_loggers.file
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+            logFormat:
+              jsonFormat:
+                method: '%REQ(:METHOD)%'
+                path: '%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%'
+                protocol: '%PROTOCOL%'
+                response_code: '%RESPONSE_CODE%'
+                start_time: '%START_TIME%'
+            path: /dev/stdout
+        - filter:
+            extensionFilter:
+              name: envoy.access_loggers.extension_filters.cel
+              typedConfig:
+                '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                expression: response.code >= 400
+          name: envoy.access_loggers.open_telemetry
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.open_telemetry.v3.OpenTelemetryAccessLogConfig
+            attributes:
+              values:
+              - key: k8s.namespace.name
+                value:
+                  stringValue: '%ENVIRONMENT(ENVOY_GATEWAY_NAMESPACE)%'
+              - key: k8s.pod.name
+                value:
+                  stringValue: '%ENVIRONMENT(ENVOY_POD_NAME)%'
+              - key: response_code
+                value:
+                  stringValue: '%RESPONSE_CODE%'
+            body:
+              stringValue: |
+                [%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)%" "%UPSTREAM_HOST%"
+            commonConfig:
+              grpcService:
+                envoyGrpc:
+                  authority: otel-collector.default.svc.cluster.local
+                  clusterName: accesslog-0
+              logName: otel_envoy_accesslog
+              transportApiVersion: V3
+            resourceAttributes:
+              values:
+              - key: cluster_name
+                value:
+                  stringValue: cluster1
+        commonHttpProtocolOptions:
+          headersWithUnderscoresAction: REJECT_REQUEST
+        http2ProtocolOptions:
+          initialConnectionWindowSize: 1048576
+          initialStreamWindowSize: 65536
+          maxConcurrentStreams: 100
+        httpFilters:
+        - name: envoy.filters.http.router
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+            suppressEnvoyHeaders: true
+        mergeSlashes: true
+        normalizePath: true
+        pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT
+        rds:
+          configSource:
+            ads: {}
+            resourceApiVersion: V3
+          routeConfigName: first-listener
+        serverHeaderTransformation: PASS_THROUGH
+        statPrefix: http
+        useRemoteAddress: true
+    name: first-listener
+  drainType: MODIFY_ONLY
+  name: first-listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.routes.yaml
new file mode 100644
index 000000000..d4a7fa5ae
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-cel.routes.yaml
@@ -0,0 +1,14 @@
+- ignorePortInHostMatching: true
+  name: first-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: first-listener/*
+    routes:
+    - directResponse:
+        body:
+          inlineString: 'Unknown custom filter type: UnsupportedType'
+        status: 500
+      match:
+        prefix: /
+      name: direct-route
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.clusters.yaml
new file mode 100644
index 000000000..b8874bf24
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.clusters.yaml
@@ -0,0 +1,49 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: direct-route-dest
+  lbPolicy: LEAST_REQUEST
+  name: direct-route-dest
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_ONLY
+  dnsRefreshRate: 30s
+  lbPolicy: LEAST_REQUEST
+  loadAssignment:
+    clusterName: accesslog-0
+    endpoints:
+    - lbEndpoints:
+      - endpoint:
+          address:
+            socketAddress:
+              address: otel-collector.default.svc.cluster.local
+              portValue: 4317
+        loadBalancingWeight: 1
+      loadBalancingWeight: 1
+      locality:
+        region: accesslog-0/backend/0
+  name: accesslog-0
+  outlierDetection: {}
+  perConnectionBufferLimitBytes: 32768
+  respectDnsTtl: true
+  type: STRICT_DNS
+  typedExtensionProtocolOptions:
+    envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
+      '@type': type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
+      explicitHttpConfig:
+        http2ProtocolOptions: {}
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.endpoints.yaml
new file mode 100644
index 000000000..20c80b3aa
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.endpoints.yaml
@@ -0,0 +1,12 @@
+- clusterName: direct-route-dest
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 1.2.3.4
+            portValue: 50000
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality:
+      region: direct-route-dest/backend/0
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.listeners.yaml
new file mode 100644
index 000000000..0bca441a4
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.listeners.yaml
@@ -0,0 +1,220 @@
+- accessLog:
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: request.url_path.contains('v1beta3')
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.file
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+      logFormat:
+        textFormatSource:
+          inlineString: |
+            [%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)%" "%UPSTREAM_HOST%"
+      path: /dev/stdout
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: request.url_path.contains('v1beta3')
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.file
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+      logFormat:
+        jsonFormat:
+          method: '%REQ(:METHOD)%'
+          path: '%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%'
+          protocol: '%PROTOCOL%'
+          response_code: '%RESPONSE_CODE%'
+          start_time: '%START_TIME%'
+      path: /dev/stdout
+  - filter:
+      andFilter:
+        filters:
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: response.code >= 400
+        - extensionFilter:
+            name: envoy.access_loggers.extension_filters.cel
+            typedConfig:
+              '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+              expression: request.url_path.contains('v1beta3')
+        - responseFlagFilter:
+            flags:
+            - NR
+    name: envoy.access_loggers.open_telemetry
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.access_loggers.open_telemetry.v3.OpenTelemetryAccessLogConfig
+      attributes:
+        values:
+        - key: k8s.namespace.name
+          value:
+            stringValue: '%ENVIRONMENT(ENVOY_GATEWAY_NAMESPACE)%'
+        - key: k8s.pod.name
+          value:
+            stringValue: '%ENVIRONMENT(ENVOY_POD_NAME)%'
+        - key: response_code
+          value:
+            stringValue: '%RESPONSE_CODE%'
+      body:
+        stringValue: |
+          [%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)%" "%UPSTREAM_HOST%"
+      commonConfig:
+        grpcService:
+          envoyGrpc:
+            authority: otel-collector.default.svc.cluster.local
+            clusterName: accesslog-0
+        logName: otel_envoy_accesslog
+        transportApiVersion: V3
+      resourceAttributes:
+        values:
+        - key: cluster_name
+          value:
+            stringValue: cluster1
+  address:
+    socketAddress:
+      address: 0.0.0.0
+      portValue: 10080
+  defaultFilterChain:
+    filters:
+    - name: envoy.filters.network.http_connection_manager
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+        accessLog:
+        - filter:
+            andFilter:
+              filters:
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: response.code >= 400
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: request.url_path.contains('v1beta3')
+          name: envoy.access_loggers.file
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+            logFormat:
+              textFormatSource:
+                inlineString: |
+                  [%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)%" "%UPSTREAM_HOST%"
+            path: /dev/stdout
+        - filter:
+            andFilter:
+              filters:
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: response.code >= 400
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: request.url_path.contains('v1beta3')
+          name: envoy.access_loggers.file
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
+            logFormat:
+              jsonFormat:
+                method: '%REQ(:METHOD)%'
+                path: '%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%'
+                protocol: '%PROTOCOL%'
+                response_code: '%RESPONSE_CODE%'
+                start_time: '%START_TIME%'
+            path: /dev/stdout
+        - filter:
+            andFilter:
+              filters:
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: response.code >= 400
+              - extensionFilter:
+                  name: envoy.access_loggers.extension_filters.cel
+                  typedConfig:
+                    '@type': type.googleapis.com/envoy.extensions.access_loggers.filters.cel.v3.ExpressionFilter
+                    expression: request.url_path.contains('v1beta3')
+          name: envoy.access_loggers.open_telemetry
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.access_loggers.open_telemetry.v3.OpenTelemetryAccessLogConfig
+            attributes:
+              values:
+              - key: k8s.namespace.name
+                value:
+                  stringValue: '%ENVIRONMENT(ENVOY_GATEWAY_NAMESPACE)%'
+              - key: k8s.pod.name
+                value:
+                  stringValue: '%ENVIRONMENT(ENVOY_POD_NAME)%'
+              - key: response_code
+                value:
+                  stringValue: '%RESPONSE_CODE%'
+            body:
+              stringValue: |
+                [%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)%" "%UPSTREAM_HOST%"
+            commonConfig:
+              grpcService:
+                envoyGrpc:
+                  authority: otel-collector.default.svc.cluster.local
+                  clusterName: accesslog-0
+              logName: otel_envoy_accesslog
+              transportApiVersion: V3
+            resourceAttributes:
+              values:
+              - key: cluster_name
+                value:
+                  stringValue: cluster1
+        commonHttpProtocolOptions:
+          headersWithUnderscoresAction: REJECT_REQUEST
+        http2ProtocolOptions:
+          initialConnectionWindowSize: 1048576
+          initialStreamWindowSize: 65536
+          maxConcurrentStreams: 100
+        httpFilters:
+        - name: envoy.filters.http.router
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+            suppressEnvoyHeaders: true
+        mergeSlashes: true
+        normalizePath: true
+        pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT
+        rds:
+          configSource:
+            ads: {}
+            resourceApiVersion: V3
+          routeConfigName: first-listener
+        serverHeaderTransformation: PASS_THROUGH
+        statPrefix: http
+        useRemoteAddress: true
+    name: first-listener
+  drainType: MODIFY_ONLY
+  name: first-listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.routes.yaml
new file mode 100644
index 000000000..d4a7fa5ae
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/accesslog-multi-cel.routes.yaml
@@ -0,0 +1,14 @@
+- ignorePortInHostMatching: true
+  name: first-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: first-listener/*
+    routes:
+    - directResponse:
+        body:
+          inlineString: 'Unknown custom filter type: UnsupportedType'
+        status: 500
+      match:
+        prefix: /
+      name: direct-route