How to setup Istio Service Mesh Instrumentation for Traffic Monitoring
Last updated: October 9, 2025
Overview
This guide walks you through setting up Istio service mesh instrumentation to capture request and response flows using a Lua script embedded in an EnvoyFilter.
The captured data will be sent to Astra Traffic Collector (ATC) over OTLP/HTTP.
We will cover:
Preparing the Astra Traffic Collector with an OTLP/HTTP receiver.
Allowing Istio to send traffic to the collector.
Adding an EnvoyFilter with a Lua script to intercept requests/responses.
Configuring and deploying the integration.

Illustration: High-level integration flow between Istio Service Mesh and Astra Traffic Collector
Prerequisites
You have access to a Kubernetes cluster where Istio is installed and configured as the networking solution (sidecar service mesh)
Astra Traffic Collector is available and reachable.
Istio instrumentation is created and you have the
sensorIDhandy
Quick Installation
Step 1: Set Up OTLP/HTTP Receiver in Astra Traffic Collector
You can configure Astra Traffic Collector to receive OTLP data over HTTP.
📄 How To Set Up OTLP/HTTP Receiver in Astra Traffic Collector
Step 2: Enable External HTTP Calls in Istio
By default, Istio blocks outbound traffic to unknown external services.
We need to explicitly allow connections from Envoy to the collector.
Create a file named allow-otel-collector-calls.yaml and add the following YAML into the file:
Replace the metadata.namespace , spec.hosts and spec.ports as mentioned below
Variable | Description | Example Value |
| namespace under which this ServiceEntry is deployed. |
|
| OTLP/HTTP endpoint of the Astra Traffic Collector. Copy just host name without |
|
| port under which Astra Traffic Collector is listening |
|
apiVersion: networking.istio.io/v1alpha3
kind: ServiceEntry
metadata:
name: allow-otel-collector-endpoint
namespace: istio-system
spec:
hosts:
- "astra-traffic-collector.astra-collector" # Replace with your collector's domain
ports:
- number: 4318
name: http-otlp
protocol: HTTP
resolution: DNS
location: MESH_INTERNAL
This ServiceEntry allows the Lua filter to make external HTTPS calls to your collector endpoint.
spec.location should be MESH_EXTERNAL if the spec.hosts is pointing to Astra Traffic Collector which is outside the Kubernetes cluster. In this case, set the spec.ports.protocol to HTTPS
Step 3: Create Istio Service Mesh Integration
We will use an EnvoyFilter with a Lua script to:
Intercept inbound and outbound HTTP traffic.
Capture both request and response bodies.
Send them to the Astra Traffic Collector over OTLP/HTTP.
Create a file named mesh-integration.yaml and add the following YAML into the file:
You need to provide certain values into the YAML file defined below. To do so, copy the YAML file into a notepad and search for keyword: Astra otel configuration. It should be visible in two places. That will be the place where you need to make changes as suggested by following table.
Variable | Description | Example Value |
| namespace under which this ServiceEntry is deployed. Should be same namespace under which istio gateway is installed. |
|
| Sensor ID of type UUID from Astra dashboard. |
|
| OTLP/HTTP endpoint of the Astra Traffic Collector. |
|
| Kubernetes app with matching selector label will be instrumented. |
|
# -----------------------------------------------------------------------------
# EnvoyFilter for Astra OpenTelemetry Tracing
#
# This configuration injects a Lua filter into the Istio Service Mesh.
# The Lua script captures request/response metadata (headers, body, timing, etc.)
# and forwards them as OpenTelemetry (OTEL) spans to the configured collector.
#
# Why this is needed:
# - Istio does not natively capture full request/response bodies for tracing.
# - With this filter, Astra can provide deep observability into HTTP transactions.
# - Spans are enriched with HTTP, network, and custom attributes before being sent.
# -----------------------------------------------------------------------------
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: astra-istio-mesh-filter
namespace: istio-system
spec:
workloadSelector:
labels:
# -----------------------------------------------------------------------
# Select the Kubernetes Applications with Istio Service Mesh to apply this filter on.
# Replace "productpage" with the actual Application label.
#
# Only the Applications with this label will have the Lua OTEL filter injected.
# -----------------------------------------------------------------------
# Astra otel configuration. Replace this "productpage" with the actual app selector that needs to be observed.
app: productpage
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_INBOUND
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: |
-------------------------------------------------------------------
-- Configuration Parameters
-- Tune these values for body size limits, sampling, and collector URL
-------------------------------------------------------------------
local config = {
-- 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 = {
-- Astra otel configuration
-- Example: "http://astra-traffic-collector.astra-collector:4318/v1/traces"
collectorUrl = "",
resource = {
-- replace your-sensor-id-uuid with actual sensorID
sensor_id = "your-sensor-id-uuid",
}
}
}
-------------------------------------------------------------------
-- Header Allowlist
-- Only these headers will be recorded in spans.
-- Covers common request/response, tracing, security, and CORS headers.
-------------------------------------------------------------------
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"
}
-------------------------------------------------------------------
-- should_sample()
-- Randomly decides whether to trace this request based on sampling ratio.
-------------------------------------------------------------------
function should_sample()
local random_value = math.random()
return random_value <= (config.sampling_ratio or 1.0)
end
-------------------------------------------------------------------
-- filter_headers()
-- Filters request/response headers to only capture allowlisted ones.
-------------------------------------------------------------------
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
-------------------------------------------------------------------
-- random_hex()
-- Generates a random hexadecimal string for trace/span IDs.
-------------------------------------------------------------------
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
-------------------------------------------------------------------
-- envoy_on_request()
-- Triggered at request ingress.
-- Captures HTTP method, path, headers, body, and client info.
-- Skips tracing for static paths or missing mandatory headers.
-------------------------------------------------------------------
function envoy_on_request(request_handle)
local path = request_handle:headers():get(":path")
if not should_sample() then
request_handle:logWarn("[Astra Instrumentation] Request not sampled due to sampling ratio. Path: " .. path)
return
end
if path:match("^/static") then
request_handle:logWarn("[Astra Instrumentation] Skipping static path: " .. path)
return
end
request_handle:logInfo("[Astra Instrumentation] Processing request for path: " .. path)
local transaction = {
timestamp = os.date("!%Y-%m-%dT%H:%M:%S.000Z"),
protocol = "1.1",
peer_address = "",
trace_id = random_hex(32),
span_id = random_hex(16),
start_time = math.floor(os.time() * 1000000000),
request = {
target = "",
host = "",
method = "",
scheme = "",
headers = {},
body = "",
body_size = 0
},
response = {
status = "",
headers = {},
body = "",
body_size = 0
}
}
transaction.request.headers = filter_headers(request_handle:headers())
-- Get request body
local req_body = ""
for chunk in request_handle:bodyChunks() do
local chunk_size = chunk:length()
if chunk_size > 0 then
req_body = req_body .. chunk:getBytes(0, chunk_size)
end
end
transaction.request.body = req_body
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:logWarn("[Astra Instrumentation] Missing mandatory headers, skipping otel tracing. Path: " .. path)
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
-- Store the transaction in the dynamic metadata for later use in the response filter
request_handle:streamInfo():dynamicMetadata():set("lua", "astra_transaction", transaction)
request_handle:logDebug("[Astra Instrumentation] Transaction stored in metadata with trace_id: " .. transaction.trace_id .. ". Path: " .. path)
end
-------------------------------------------------------------------
-- envoy_on_response()
-- Triggered before sending response back.
-- Captures response headers/body, finalizes span, and sends OTEL payload.
-------------------------------------------------------------------
function envoy_on_response(response_handle)
-- Get the transaction from the dynamic metadata
local transaction = response_handle:streamInfo():dynamicMetadata():get("lua")["astra_transaction"]
-- If the transaction is not found or is nil, return
if not transaction or transaction == nil then
response_handle:logWarn("[Astra Instrumentation] No astra_transaction found in metadata")
return
end
response_handle:logInfo("[Astra Instrumentation] Processing response for trace_id: " .. (transaction.trace_id or "unknown"))
transaction.response.headers = filter_headers(response_handle:headers())
-- Get response body (decompressor filter handles decompression)
local resp_body = ""
for chunk in response_handle:bodyChunks() do
local chunk_size = chunk:length()
if chunk_size > 0 then
resp_body = resp_body .. chunk:getBytes(0, chunk_size)
end
end
transaction.response.body = resp_body
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" } }
}
},
scopeSpans = {
{
scope = {
-- Name of your instrumented gateway. Helpful in identification of spans
name = "astra-istio-servicemesh"
},
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 = "sensor.id", value = { stringValue = config.otel.resource.sensor_id } },
{ 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 headers = {
[":method"] = "POST",
[":path"] = path,
[":authority"] = host,
["content-type"] = "application/json",
["content-length"] = tostring(#json_payload)
}
local success, result = pcall(function()
local resp_headers, resp_body = response_handle:httpCall(
cluster_name,
headers,
json_payload,
5000
)
local status = resp_headers[":status"]
if status then
local status_code = tonumber(status)
if status_code and status_code >= 200 and status_code < 300 then
response_handle:logDebug("[Astra Instrumentation] OTEL trace sent successfully for trace_id: " .. transaction.trace_id .. ", status: " .. status)
else
response_handle:logErr("[Astra Instrumentation] OTEL collector returned error status: " .. status .. " for trace_id: " .. transaction.trace_id)
end
else
response_handle:logWarn("[Astra Instrumentation] OTEL call completed but no status code found")
end
end)
if not success then
response_handle:logErr("[Astra Instrumentation] httpCall failed: " .. tostring(result))
end
transaction = nil
end
-------------------------------------------------------------------
-- JSON Encoding
-- Proper JSON encoding implementation with escape handling
-------------------------------------------------------------------
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
return "null"
end
return string.format("%.14g", val)
end
local encode
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference check
if stack[val] then
return "null"
end
stack[val] = true
local is_array = false
if rawget(val, 1) ~= nil or next(val) == nil then
-- Check if it's a valid array
local n = 0
is_array = true
for k in pairs(val) do
if type(k) ~= "number" then
is_array = false
break
end
n = n + 1
end
if is_array and n ~= #val then
is_array = false
end
end
if is_array then
-- Encode as array
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Encode as object
for k, v in pairs(val) do
if type(k) == "string" then
table.insert(res, encode_string(k) .. ":" .. encode(v, stack))
end
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
return "null"
end
function json_encode(val)
return encode(val)
end
Step 4: 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 mesh-integration.yaml
Verify the traces in Astra Traffic Collector
As and when the selected app is receiving HTTP traffic, you should be able to see the traces in astra-traffic-collector