Envoy’s access logs aren’t just a record of requests; they’re a real-time, structured stream of the exact network conversations happening at your edge.

Let’s see what that looks like. Imagine a simple Envoy configuration that’s forwarding traffic.

admin:
  access_log_path: /dev/stdout
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: cluster_0
          http_filters:
          - name: envoy.filters.http.router
            typed_config: {}
  clusters:
  - name: cluster_0
    connect_timeout: 0.25s
    type: LOGICAL_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: cluster_0
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: example.com
                port_value: 80

When a request comes in, Envoy can be configured to write a line to its access log. By default, this is often a plain-text format. But we want structured data, specifically JSON, for easier parsing by downstream systems.

To get JSON output, you need to specify the json_format field within the access_log configuration for your listener. This tells Envoy to serialize the access log entry into a JSON object.

# ... within listener_0's http_connection_manager config ...
access_log:
  - name: envoy.access_loggers.file
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
      path: /dev/stdout
      json_format: {} # This empty object requests default JSON formatting

When a request hits our listener_0 on port 8080 and gets routed to example.com:80, the access log might look like this JSON output on /dev/stdout:

{
  "start_time": "2023-10-27T10:00:00.123Z",
  "request_id": "123e4567-e89b-12d3-a456-426614174000",
  "duration_ms": 50,
  "response_flags": "",
  "bytes_sent": 1024,
  "bytes_received": 256,
  "local_address": "192.168.1.10:8080",
  "remote_address": "10.0.0.5:54321",
  "downstream_local_port": 8080,
  "downstream_remote_port": 54321,
  "upstream_service_time_ms": 40,
  "upstream_host": "tcp://example.com:80",
  "method": "GET",
  "path": "/some/resource",
  "protocol": "HTTP/1.1",
  "response_code": 200,
  "response_code_details": "via_upstream",
  "user_agent": "curl/7.68.0",
  "request_headers": {
    "x-request-id": "123e4567-e89b-12d3-a456-426614174000",
    "host": "example.com",
    "user-agent": "curl/7.68.0"
  },
  "response_headers": {
    "content-type": "application/json",
    "date": "Fri, 27 Oct 2023 10:00:00 GMT"
  }
}

This default JSON includes many useful fields. However, you often need more control. You might want to add specific request headers, or perhaps only log certain fields to reduce log volume. This is where custom_json_format comes in.

custom_json_format allows you to define a template for your JSON output, using Envoy’s built-in formatters. These formatters are essentially key-value pairs where the value is a string that can reference various request and response attributes.

Here’s how you’d configure a custom JSON format:

# ... within listener_0's http_connection_manager config ...
access_log:
  - name: envoy.access_loggers.file
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
      path: /dev/stdout
      json_format:
        request_id: "%REQ(X-REQUEST-ID)%"
        method: "%REQ(:METHOD)%"
        path: "%REQ(:PATH)%"
        status: "%RESPONSE_CODE%"
        duration_ms: "%DURATION%"
        user_agent: "%REQ(USER-AGENT)%"
        upstream_host: "%UPSTREAM_HOST%"
        remote_addr: "%REMOTE_ADDRESS%"

In this json_format block, we’re explicitly defining the fields we want.

  • %REQ(HEADER-NAME)% extracts a request header. Notice :METHOD and :PATH are special pseudo-headers for HTTP.
  • %RESPONSE_CODE% gets the HTTP status code.
  • %DURATION% is the total request duration.
  • %UPSTREAM_HOST% shows which upstream cluster was targeted.
  • %REMOTE_ADDRESS% is the client’s IP.

The actual output for the same request would now look like this:

{
  "request_id": "123e4567-e89b-12d3-a456-426614174000",
  "method": "GET",
  "path": "/some/resource",
  "status": 200,
  "duration_ms": 50,
  "user_agent": "curl/7.68.0",
  "upstream_host": "tcp://example.com:80",
  "remote_addr": "10.0.0.5:54321"
}

This gives you fine-grained control. You can even embed other JSON objects. For instance, to log a subset of request headers:

# ... within listener_0's http_connection_manager config ...
access_log:
  - name: envoy.access_loggers.file
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
      path: /dev/stdout
      json_format:
        request_id: "%REQ(X-REQUEST-ID)%"
        method: "%REQ(:METHOD)%"
        path: "%REQ(:PATH)%"
        status: "%RESPONSE_CODE%"
        duration_ms: "%DURATION%"
        client_info:
          user_agent: "%REQ(USER-AGENT)%"
          remote_addr: "%REMOTE_ADDRESS%"

This would produce:

{
  "request_id": "123e4567-e89b-12d3-a456-426614174000",
  "method": "GET",
  "path": "/some/resource",
  "status": 200,
  "duration_ms": 50,
  "client_info": {
    "user_agent": "curl/7.68.0",
    "remote_addr": "10.0.0.5:54321"
  }
}

The power of json_format lies in its extensibility. You can reference almost any aspect of the request and response lifecycle, from connection details to specific headers and timings. The key is understanding the available formatters and how they map to Envoy’s internal data structures.

What most people miss is that json_format can also include literal strings that become JSON keys and values, and that you can combine literal strings with formatters. For example, to ensure a field like envoy_version is always present, you can do:

      json_format:
        envoy_version: "v1.25.0" # Literal string
        request_id: "%REQ(X-REQUEST-ID)%"
        method: "%REQ(:METHOD)%"

This allows you to enrich your logs with static metadata about the Envoy instance itself, making it easier to correlate logs across different deployments.

The next step is often to filter which requests get logged at all, using the access_log_filter field within the FileAccessLog configuration.

Want structured learning?

Take the full Envoy course →