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
Option 2: HTTPS Configuration (Recommended for Production)
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
Thank you!