Articles on: API Endpoints

How to setup Istio Ingress Gateway Instrumentation for Traffic Monitoring

Overview



This guide explains how to set up Istio ingress gateway Instrumentation to instrument request and response flows with Lua script.



Prerequisites


Access to a kubernetes cluster with Istio as networking solution

Step 1: Setting up OTLP/HTTP Receiver in Astra Traffic Collector



You can configure the Astra Traffic Collector to receive data over HTTP or HTTPS. For production environments, HTTPS is recommended for secure data transmission

Option 1: HTTP Configuration (Development/Testing)


Open config_custom.yaml in your traffic collector's instance and update the receivers section
receivers:
  otlp:
    protocols:
      http:
        endpoint: "0.0.0.0:4318"  # HTTP receiver on port 4318

processors:
 #...


save the file once edited and restart the collector using systemctl restart astra-traffic-collector.service



Update the same config_custom.yaml to use HTTPS with your cert and key files from the trusted authority:
receivers:
  otlp:
    protocols:
      http:
        endpoint: "0.0.0.0:4318"  # HTTP receiver on port 4318
        tls:
          cert_file: "/etc/otelcol-contrib/<cert-file>" 
          key_file: "/etc/otelcol-contrib/<privkey-file>"


The third field is ca_file, which is used when the certificate is self-signed or from an untrusted CA.
If your certificate is issued by a trusted CA (e.g., Let's Encrypt), you don’t need to specify ca_file.

Ensure the certificate files are correctly placed in /etc/otelcol-contrib/ and have proper permissions. The private key should be readable only by the owner (for security reasons) & The certificate can be readable by others but should not be writable:

Next, modify your volume section in the docker-compose.yaml by adding the following lines in your Astra Traffic Collector to include volume mounts for the certificates:

volumes:
      - <path_to_certificates>/<cert-file>:/etc/otelcol-contrib/<cert-file>:ro
      - <path_to_certificates>/<privkey-file>:/etc/otelcol-contrib/<privkey-file>:ro


save the file once edited and restart the collector using systemctl restart astra-traffic-collector.service

Step 2: Enable External HTTP Calls in Istio



Before applying the Lua filter, we need to allow Istio to make external HTTP calls to the collector. Create a file named allow-external-calls.yaml:

apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
  name: allow-collector-endpoint
  namespace: default
spec:
  hosts:
  - "your.collector.com"  # Replace with your collector's domain
  ports:
  - number: 4318
    name: https
    protocol: HTTPS
  resolution: DNS
  location: MESH_EXTERNAL


This ServiceEntry allows the Lua filter to make external HTTPS calls to your collector endpoint.

Step 3: Create the Istio Ingress Integration



Create a file named ingress-integration.yaml:
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: astra-istio-gateway-filter
  namespace: default
spec:
  workloadSelector:
    labels:
      # gateway.networking.k8s.io/gateway-name: bookinfo-gateway
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: GATEWAY
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.lua
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
            inlineCode: |
              local config = {
                -- Maximum size in bytes for request/response bodies
                -- Bodies larger than this will be truncated
                max_body_size = 10000,

                -- Sampling ratio: value between 0 to 1
                -- 0 = no requests sampled
                -- 1 = all requests sampled (100%)
                -- 0.5 = 50% of requests sampled
                sampling_ratio = 1,

                -- Protocol scheme for the requests
                -- Must be either "http" or "https"
                -- Leave empty to auto-detect from request
                scheme = "",

                otel = {
                  -- OpenTelemetry configuration
                  -- Example: "http://otel-collector.default:4318/v1/traces"
                  collectorUrl = "",
                  resource = {
                    sensor_id = "",
                  }
                }
              }

              local header_names = {
                -- Request headers
                "accept", "accept-encoding", "accept-language", "cache-control",
                "connection", "content-length", "content-type", "cookie",
                "host", "pragma", "referer", "user-agent", "origin", "authorization",
                "if-match", "if-none-match", "if-modified-since", "if-unmodified-since",
                "range", "te", "upgrade", "via", "warning",
                
                -- Response headers
                "age", "allow", "cache-control", "content-disposition", "content-encoding",
                "content-language", "content-length", "content-location", "content-range",
                "content-type", "date", "etag", "expires", "last-modified", "location",
                "server", "set-cookie", "vary", "connection", "keep-alive", "proxy-authenticate",
                "proxy-authorization", "transfer-encoding", "upgrade", "via", "warning",
                
                -- Tracing headers
                "x-request-id",
                
                -- Forwarded headers
                "forwarded", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto",
                "x-forwarded-port", "x-forwarded-client-cert", "x-real-ip",
                
                -- Envoy headers
                "x-envoy-upstream-service-time", "x-envoy-internal", "x-envoy-decorator-operation",
                "x-envoy-expected-rq-timeout-ms", "x-envoy-original-path", "x-envoy-attempt-count",
                "x-envoy-external-address", "x-envoy-force-trace", "x-envoy-original-dst-host",
                "x-envoy-max-retries", "x-envoy-retry-on", "x-envoy-retriable-status-codes",
                "x-envoy-retriable-header-names", "x-envoy-retry-grpc-on", "x-envoy-downstream-service-cluster",
                "x-envoy-downstream-service-node", "x-envoy-local-overloaded", "x-envoy-peer-metadata",
                "x-envoy-peer-metadata-id", "x-envoy-expected-rq-timeout-ms", "x-envoy-upstream-service-time",
                
                -- Istio headers
                "x-istio-attributes", "x-istio-request-id", "x-istio-auth-userinfo",
                
                -- CORS headers
                "access-control-allow-origin", "access-control-allow-methods", 
                "access-control-allow-headers", "access-control-max-age",
                "access-control-allow-credentials", "access-control-expose-headers",
                "access-control-request-method", "access-control-request-headers",
                
                -- Security headers
                "strict-transport-security", "x-content-type-options", "x-frame-options",
                "x-xss-protection", "content-security-policy", "referrer-policy",
                "cross-origin-resource-policy", "cross-origin-opener-policy",
                "cross-origin-embedder-policy",

                -- WebSocket headers
                "sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol",
                "sec-websocket-accept", "sec-websocket-extensions"
              }

              function should_sample()
                local random_value = math.random()
                return random_value <= (config.sampling_ratio or 1.0)
              end

              function filter_headers(headers)
                if not headers then return {} end
                local result = {}
                for _, name in ipairs(header_names) do
                  local val = headers:get(name)
                  if val then
                    result[name] = val
                  end
                end
                return result
              end

              function get_body(handle)
                if handle:body() then
                  local body_size = handle:body():length()
                  if body_size > config.max_body_size then
                    return handle:body():getBytes(0, config.max_body_size) .. " [truncated, full size: " .. body_size .. " bytes]"
                  end
                  return handle:body():getBytes(0, body_size)
                end
                return ""
              end

              function random_hex(length)
                local chars = "0123456789abcdef"
                local result = ""
                for i = 1, length do
                  local rand = math.random(1, #chars)
                  result = result .. string.sub(chars, rand, rand)
                end
                return result
              end

              local function init_transaction()
                local current_time = math.floor(os.time() * 1000000000)
                return {
                  timestamp = "",
                  protocol = "1.1",
                  peer_address = "",
                  trace_id = random_hex(32),
                  span_id = random_hex(16),
                  start_time = current_time,
                  request = {
                    target = "",
                    host = "",
                    method = "",
                    scheme = "",
                    headers = {},
                    body = "",
                    body_size = 0
                  },
                  response = {
                    status = "",
                    headers = {},
                    body = "",
                    body_size = 0
                  }
                }
              end

              transaction = nil

              function envoy_on_request(request_handle)
                math.randomseed(os.time())
                
                if not should_sample() then
                  transaction = nil
                  return
                end

                local path = request_handle:headers():get(":path")
                if path:match("^/static") then
                  transaction = nil
                  return
                end

                transaction = init_transaction()
                transaction.timestamp = os.date("!%Y-%m-%dT%H:%M:%S.000Z")
                
                request_handle:body()

                transaction.request.headers = filter_headers(request_handle:headers())
                transaction.request.body = get_body(request_handle)
                transaction.request.body_size = #transaction.request.body

                transaction.request.method = request_handle:headers():get(":method") or ""
                transaction.request.target = request_handle:headers():get(":path") or ""
                transaction.request.host = request_handle:headers():get(":authority") or transaction.request.headers["host"] or ""
                transaction.request.scheme = config.scheme or request_handle:headers():get(":scheme") or "https"

                if transaction.request.headers["x-forwarded-proto"] == "https" then
                  transaction.request.scheme = "https"
                end

                if not transaction.request.method or not transaction.request.target or not transaction.request.host then
                  request_handle:logDebug("Missing mandatory headers, skipping otel tracing")
                  transaction = nil
                  return
                end

                local forwarded_for = transaction.request.headers["x-forwarded-for"]
                if forwarded_for and forwarded_for ~= "" then
                  transaction.peer_address = string.match(forwarded_for, "^([^,]+)")
                elseif transaction.request.headers["x-envoy-external-address"] then
                  transaction.peer_address = transaction.request.headers["x-envoy-external-address"]
                elseif transaction.request.headers["x-real-ip"] then
                  transaction.peer_address = transaction.request.headers["x-real-ip"]
                end

                if transaction.request.headers["x-envoy-peer-metadata-id"] then
                  local meta_id = transaction.request.headers["x-envoy-peer-metadata-id"]
                  local service_name = string.match(meta_id, "sidecar~[^~]+~([^~]+)%.")
                  if service_name then
                    transaction.peer_address = service_name
                  end
                end
              end

              function envoy_on_response(response_handle)
                if not transaction then
                  return
                end

                response_handle:body()

                transaction.response.headers = filter_headers(response_handle:headers())
                transaction.response.body = get_body(response_handle)
                
                transaction.response.status = response_handle:headers():get(":status") or ""
                transaction.response.body_size = #transaction.response.body
                transaction.end_time = math.floor(os.time() * 1000000000)

                local otel_payload = {
                  resourceSpans = {
                    {
                      resource = {
                        attributes = {
                          { key = "service.name", value = { stringValue = "astra-istio-instrumentation" } },
                          { key = "service.version", value = { stringValue = "1.0.0" } },
                          { key = "sensor.id", value = { stringValue = config.otel.resource.sensor_id } }
                        }
                      },
                      scopeSpans = {
                        {
                          scope = {
                            name = "bookinfo-gateway"
                          },
                          spans = {
                            {
                              traceId = transaction.trace_id,
                              spanId = transaction.span_id,
                              name = "example-span",
                              kind = 1,
                              startTimeUnixNano = string.format("%.0f", transaction.start_time),
                              endTimeUnixNano = string.format("%.0f", transaction.end_time),
                              attributes = {
                                { key = "http.method", value = { stringValue = transaction.request.method } },
                                { key = "http.status_code", value = { intValue = tonumber(transaction.response.status) or 0 } },
                                { key = "http.target", value = { stringValue = transaction.request.target } },
                                { key = "http.host", value = { stringValue = transaction.request.host } },
                                { key = "http.flavor", value = { stringValue = transaction.protocol } },
                                { key = "http.scheme", value = { stringValue = transaction.request.scheme } },
                                { key = "net.sock.peer.addr", value = { stringValue = transaction.peer_address } },
                                { key = "http.request.headers", value = { stringValue = json_encode(transaction.request.headers) } },
                                { key = "http.response.headers", value = { stringValue = json_encode(transaction.response.headers) } },
                                { key = "http.request.body", value = { stringValue = transaction.request.body } },
                                { key = "http.response.body", value = { stringValue = transaction.response.body } }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }

                local json_payload = json_encode(otel_payload)
                
                local protocol, host, port, path = string.match(config.otel.collectorUrl, "(%w+)://([^:/]+):?(%d*)(/?.*)")
                local cluster_name = "outbound|" .. port .. "||" .. host
                
                local success, result_or_error = pcall(function()
                  local headers = {
                    [":method"] = "POST",
                    [":path"] = path,
                    [":authority"] = host,
                    ["content-type"] = "application/json",
                    ["content-length"] = tostring(#json_payload)
                  }
                  
                  local result, err = response_handle:httpCall(
                    cluster_name,
                    headers,
                    json_payload,
                    5000
                  )
                  
                  if err then
                    return nil, "HTTP call error: " .. err
                  else
                    if result.headers and result.headers[":status"] then
                      response_handle:logInfo("OTEL collector response status: " .. result.headers[":status"])
                    end
                    return result
                  end
                end)
                
                if not success then
                  response_handle:logErr("OTEL call failed: " .. tostring(result_or_error))
                else
                  if result_or_error and result_or_error[2] then
                    response_handle:logErr("OTEL HTTP call error: " .. result_or_error[2])
                  end
                end

                transaction = nil
              end

              function json_encode(val)
                if val == nil then
                  return "null"
                elseif type(val) == "table" then
                  local is_array = true
                  local max_index = 0
                  for k, v in pairs(val) do
                    if type(k) ~= "number" or k <= 0 or math.floor(k) ~= k then
                      is_array = false
                      break
                    end
                    max_index = math.max(max_index, k)
                  end
                  
                  local count = 0
                  for _ in pairs(val) do
                    count = count + 1
                  end
                  
                  is_array = is_array and max_index == count
                  
                  if is_array then
                    local json = "["
                    local first = true
                    for i = 1, max_index do
                      if not first then json = json .. "," end
                      first = false
                      json = json .. json_encode(val[i])
                    end
                    return json .. "]"
                  else
                    local json = "{"
                    local first = true
                    for k, v in pairs(val) do
                      if not first then json = json .. "," end
                      first = false
                      json = json .. '"' .. escape(tostring(k)) .. '":' .. json_encode(v)
                    end
                    return json .. "}"
                  end
                elseif type(val) == "number" then
                  return tostring(val)
                elseif type(val) == "boolean" then
                  return tostring(val)
                else
                  return '"' .. escape(tostring(val)) .. '"'
                end
              end
              
              function serialize(tbl)
                return json_encode(tbl)
              end

              function escape(s)
                if s == nil then return "" end
                return string.gsub(string.gsub(string.gsub(s, '\\', '\\\\'), '"', '\\"'), '\n', '\\n')
              end


Step 4: Configure the Integration



Before applying the configuration, you need to modify values in both files:

ServiceEntry Configuration


Update the hosts field with your collector's domain name:

hosts:
   - "your.collector.com"  # Replace with your collector domain


Adjust the ports if using a different port number:

ports:
   - number: 4318  # Change if using different port
     name: https
     protocol: HTTPS  # Change to HTTP if not using TLS


EnvoyFilter Configuration


Workload Selector: Update the label to match your application:

workloadSelector:
    labels:
      # gateway.networking.k8s.io/gateway-name: your-gateway

To find your app's label, run:

kubectl get pods --show-labels


Collector URL: Update the collectorUrl in the config section:

otel = {
     collectorUrl = "https://your-collector-domain:4318/v1/traces",  # Replace with your collector URL
     resource = {
       sensor_id = "your-sensor-id"
     }
   }


Sensor ID: Update the sensor_id with the ID from your Astra dashboard:

resource = {
     sensor_id = "your-sensor-id"  # Replace with your sensor ID from Astra
   }


Step 5: Apply the Configuration



Apply both configurations in order:

# First, apply the ServiceEntry to allow external calls
kubectl apply -f allow-external-calls.yaml

# Then, apply the EnvoyFilter
kubectl apply -f ingress-integration.yaml

Updated on: 17/04/2025

Was this article helpful?

Share your feedback

Cancel

Thank you!