Traffic Logging¶
Overview¶
Traffic Logging writes a single structured JSON line for every request handled by the gateway — who called what, the status code, latencies, request/response headers, and (optionally) bodies.
Each line is written to the sinks you name in traffic_logging.outputs:
| Sink | Where the line goes | Use it when |
|---|---|---|
stdout |
The policy-engine container log, and therefore the node's /var/log/pods files |
A node-level collector (Fluent Bit, Fluentd, CloudWatch agent) is already scraping container logs, and the lines carry no payloads you need to keep off the node. This is the default. |
file |
A rotating file on a writable volume | You want payloads out of the container log and away from every node-level collector, and you have (or can mount) a per-pod volume. |
http |
Batched POSTs to a log receiver you name |
You want the lines to leave the pod directly — no disk, no co-located collector, no sidecar or DaemonSet. |
Sinks are additive: outputs = ["file", "http"] writes every line to both. Order is irrelevant,
duplicates are rejected, and an unknown name is a startup error.
Note
Traffic Logging and Moesif Analytics are independent consumers of the same underlying data-capture pipeline. You can enable either, both, or neither.
How it works¶
Request/response header and body capture is a shared concern, handled by a capture pipeline called
the collector. The collector has no on/off switch of its own. It activates automatically whenever
a consumer that needs it is enabled (analytics.enabled or traffic_logging.enabled). Traffic Logging
is one such consumer: it reads whatever the collector captured, serializes it to JSON, and hands the
line to each configured sink.
Envoy access log ──ALS──► collector ──► traffic-log serializer ──┬──► stdout container log (kubectl logs, node collectors)
├──► file rotating file on a per-pod volume
└──► http bounded queue ──► batched POST to a receiver
Because emission happens on Envoy's access-log path, it fires for every request Envoy terminates — including requests denied by an auth policy before they reach any downstream logic.
Two properties hold for every sink:
- Nothing in the sink path is on the request/response path. Traffic-log lines are produced from Envoy's access-log stream, which Envoy emits after the response has been sent to the client. A slow or unreachable log receiver cannot add latency to an API call or fail one.
- Sink construction fails closed; delivery fails open. A file that cannot be opened or an endpoint that cannot be built fails gateway startup. No sink silently degrades to
stdout, because that would put payloads back into the container log the operator chosefile/httpto keep them out of. At runtime, by contrast, a delivery failure drops the line and counts it (see Monitoring the sinks); it never blocks request processing and never falls back to another sink.
Quick start (stdout)¶
At minimum, enable [traffic_logging] and turn on whichever [collector] capture flags you want
reflected in the log line. Each traffic_logging.*_headers / *_body toggle only selects among
what [collector] already captured. Enabling it while the matching [collector] flag is off has no
effect.
[collector]
request_headers = true
response_headers = true
[traffic_logging]
enabled = true
request_headers = true
response_headers = true
outputs defaults to ["stdout"], so this produces a JSON line on the policy-engine's stdout for
every request to every API, with headers redacted per masked_headers.
Configuration reference¶
[collector]¶
Shared capture pipeline. Configured once; every enabled consumer (Analytics, Traffic Logging) reads from it.
| Parameter | Type | Default | Description |
|---|---|---|---|
request_body |
boolean | false |
Capture the full request body into the collected event. |
response_body |
boolean | false |
Capture the full response body into the collected event. |
request_headers |
boolean | false |
Capture all request headers into the collected event. |
response_headers |
boolean | false |
Capture all response headers into the collected event. |
ignore_path_prefixes |
array of strings | [] |
Path prefixes for which no analytics event and no traffic-log line is produced at all — as if capture were disabled for that one request. See Ignoring paths. |
Note
Bodies can be large. Capture is off by default for both request and response bodies. Enable only
what you need, and use traffic_logging.max_payload_size to cap what reaches each log line.
[traffic_logging]¶
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Emit a JSON line for every request to every API. |
outputs |
array of strings | ["stdout"] |
Sinks each line is written to. Any combination of "stdout", "file", "http". Additive; order is irrelevant; duplicates and unknown names are startup errors. |
shutdown_timeout |
duration | "5s" |
Bounds the flush of buffering sinks on SIGTERM. Only http buffers. |
masked_headers |
array of strings | ["authorization", "x-api-key", "x-jwt-assertion"] |
Header names (case-insensitive) whose values are redacted as **** in the logged requestHeaders/responseHeaders. |
max_payload_size |
int | 0 |
Maximum bytes of request/response payload written per log line. 0 = no limit. Applied output-side only — the collector still captures the full body. |
request_headers |
boolean | false |
Include captured request headers in the log line. No-op if collector.request_headers is false. |
request_body |
boolean | false |
Include the captured request body. No-op if collector.request_body is false. |
response_headers |
boolean | false |
Include captured response headers in the log line. No-op if collector.response_headers is false. |
response_body |
boolean | false |
Include the captured response body. No-op if collector.response_body is false. |
exclude_fields |
array of strings | [] |
Drop named fields from the emitted line. See Field exclusion. |
Sub-tables: [traffic_logging.file] (see The file sink),
[traffic_logging.http] with .auth, .auth.<type> and .tls
(see The http sink), and [traffic_logging.properties]
(see Custom properties).
[collector.server] (ALS transport tuning)¶
Advanced settings for the Envoy → policy-engine access-log transport shared by the collector. Defaults are sensible for most deployments.
| Parameter | Type | Default | Description |
|---|---|---|---|
mode |
string | "uds" |
Transport mode: "uds" (Unix domain socket) or "tcp". |
als_plain_text |
boolean | true |
Use plaintext gRPC (skip TLS). |
public_key_path / private_key_path |
string | "" |
TLS keypair for the ALS connection, when als_plain_text = false. |
max_message_size |
int | 1000000000 |
Maximum size of a single gRPC message the ALS server accepts from Envoy. |
max_header_limit |
int | 8192 |
Maximum size of headers processed by the ALS server. |
shutdown_timeout |
duration | "600s" |
Maximum time allowed for graceful ALS server shutdown. |
Note
The ALS transport's TCP port is fixed at 18090 and is not configurable — this guarantees the
controller and the policy-engine can never disagree on it. buffer_flush_interval,
buffer_size_bytes, and grpc_request_timeout are additional Envoy-sender-only keys under
[collector.server]; see config-template.toml for their defaults.
The file sink¶
Appends each line to a rotating file. Use it to keep payloads off the container log and therefore out
of kubectl logs and out of any DaemonSet shipping container logs onward.
[collector]
request_body = true
response_body = true
request_headers = true
response_headers = true
[traffic_logging]
enabled = true
outputs = ["file"]
request_headers = true
request_body = true
response_headers = true
response_body = true
max_payload_size = 4096
[traffic_logging.file]
path = "/var/log/wso2/traffic/traffic.log"
max_size_mb = 100
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
string | "" |
Required when this sink is selected. Absolute path of the live log file. |
max_size_mb |
int | 100 |
Size at which the live file is rotated. 0 disables rotation and logs a warning. |
Permissions¶
The parent directory is created 0700 and the file opened 0600 at startup, so a permissions
problem surfaces at boot rather than at the first request once payloads are already flowing.
If the file already exists and is group- or world-readable — left behind by an earlier run, restored
from a backup, or pre-created on a volume — startup fails with the chmod command to run. A
permissive directory only warns, since a 0600 file is unreadable regardless of its directory's mode.
Rotation¶
At max_size_mb the live file is renamed to <path>.1 (clobbering any previous backup) and reopened,
so the worst case on disk is 2 × max_size_mb. The size counter is seeded from the existing file at
open, so the ceiling still binds after a restart that appends to an existing log.
This is the "create" mode logrotate uses, so tailers that track inodes (Fluent Bit, Filebeat, Promtail) follow it correctly.
Single writer only
path must be a per-pod volume. An emptyDir, or a PVC bound to exactly one replica. A shared
RWX volume, hostPath, or NFS mount written by more than one gateway pod is not supported and
will silently lose records.
Give each replica its own volume and let your collector (or the http sink) do
the merging.
Kubernetes¶
The Helm chart provisions and mounts the volume automatically whenever traffic_logging.outputs
contains "file". You do not need to enable it separately:
gateway:
config:
traffic_logging:
enabled: true
outputs: ["file"]
file:
path: /var/log/wso2/traffic/traffic.log
max_size_mb: 100
renders both the mount and a per-pod emptyDir:
volumeMounts:
- name: traffic-log
mountPath: /var/log/wso2
volumes:
- name: traffic-log
emptyDir:
sizeLimit: 512Mi
Tune it under gateway.gatewayRuntime.trafficLogVolume:
| Value | Default | Description |
|---|---|---|
enabled |
auto | Auto-derived from whether outputs contains "file". Set it explicitly only to override. For example false when you mount your own volume. |
mountPath |
/var/log/wso2 |
Mount point. traffic_logging.file.path must sit under this path, or templating fails with an explanatory error rather than producing a pod that cannot start. |
sizeLimit |
512Mi |
Keep this comfortably above 2 × max_size_mb so rotation bounds the file, not the kubelet. The kubelet's limit evicts the pod. |
medium |
(unset) | Set to "Memory" for a tmpfs: off disk entirely, but it counts against the container's memory limit. |
Tip
An emptyDir lives outside the /var/log/pods glob node-level collectors read, which is the point:
bodies written there are invisible to both kubectl logs and any DaemonSet shipping container logs
onward. To ship them anyway, mount the same volume into a sidecar collector.
The http sink¶
Batches lines and POSTs them to an endpoint you name. Nothing is written to disk and no co-located
collector is required.
The body is newline-delimited JSON (application/x-ndjson): one traffic-log line per line. The sink adds no envelope, no per-record metadata, and no receiver-specific framing.
Receivers that take that payload as-is include Splunk HEC's /services/collector/raw endpoint and the HTTP input of a log collector such as Fluent Bit or Vector can consume the sink straight.
[traffic_logging]
enabled = true
outputs = ["http"]
[traffic_logging.http]
endpoint = "https://logs.example.com/ingest"
content_type = "application/x-ndjson"
[traffic_logging.http.auth]
type = "bearer"
[traffic_logging.http.auth.bearer]
token = '{{ env "APIP_GW_TRAFFIC_LOG_TOKEN" }}'
Endpoint and transport¶
| Parameter | Type | Default | Description |
|---|---|---|---|
endpoint |
string | "" |
Required when this sink is selected. Absolute URL each batch is POSTed to. Redirects are never followed. |
content_type |
string | "application/x-ndjson" |
Content-Type sent with each batch. |
allow_insecure_transport |
boolean | false |
Permits a plaintext http:// endpoint. These lines can carry request and response bodies, so plaintext is a real disclosure, not a hygiene warning. Intended only for a collector on the pod network. An http:// endpoint without this set is a startup error. |
Any scheme other than https (or http with the flag above) is rejected at startup.
Batching and queueing¶
A batch is closed by whichever bound is reached first, then handed to a single sender goroutine that drains a bounded queue.
| Parameter | Type | Default | Description |
|---|---|---|---|
batch_max_events |
int | 100 |
Maximum lines per batch. Must be positive. |
batch_max_bytes |
int | 1048576 |
Maximum encoded bytes per batch. Must be positive. |
flush_interval |
duration | "5s" |
Maximum time a partial batch waits before being sent. Must be positive. |
queue_capacity |
int | 10000 |
Bounded queue between the ingest path and the sender. Must be positive. An unbounded queue in front of a bounded sender is just deferred unbounded memory growth, and these events can carry bodies. At a 10 KiB/line worst case, 10000 lines is roughly 100 MiB. |
on_queue_full |
string | "drop_new" |
What to drop once the queue is full. drop_new keeps the older window (suits audit); drop_oldest keeps recency (suits dashboards). Either way the line is counted as dropped_total{reason="queue_full"}. |
Timeouts and retries¶
| Parameter | Type | Default | Description |
|---|---|---|---|
request_timeout |
duration | "10s" |
Bounds a single POST attempt. Must be positive. |
max_retries |
int | 3 |
Retry attempts after the initial one. 0 means one attempt per batch. |
retry_backoff |
duration | "1s" |
Base for exponential backoff, plus jitter so replicas recovering from a shared outage do not synchronize. Must be positive when max_retries > 0. |
retry_abort_queue_ratio |
float | 0.5 |
Fraction of queue_capacity at which a retrying batch abandons its remaining attempts. Must be between 0 and 1. See below. |
Only transport errors, 5xx and 429 are retried. A 4xx other than 429 means the receiver rejected
the batch's shape, so retrying would only amplify a permanent failure. When a 429 carries a
Retry-After header, that delay replaces the computed backoff rather than adding to it, and is
clamped so a hostile or misconfigured value cannot stall the sender indefinitely.
Retry versus draining (retry_abort_queue_ratio)
While a batch is retrying, nothing else in the queue is being drained. Against a receiver that accepts
connections but never answers, the default retry budget holds the sender for a considerable time, and
longer still if the receiver returns 429 with a large Retry-After. At even a modest event rate that
is long enough to fill the whole queue, so retrying to save one batch of ~100 events can cost thousands
of newer ones.
retry_abort_queue_ratio bounds that trade-off: before each further wait, the sender checks the queue
depth, and at or above the threshold it abandons the batch and gets back to draining. The abandoned
lines are counted as dropped_total{reason="backpressure"}. This is deliberately distinct from send_failed,
so you can tell "the receiver is slow" from "the receiver is broken".
The value is used exactly as written and is never remapped to a default:
| Value | Behavior |
|---|---|
0 |
Always abandon retries. One delivery attempt per batch, whatever the queue looks like (the same effect as max_retries = 0). |
0.1 |
Abort once the queue is 10% full. Favors draining over saving any individual batch. |
0.5 |
Default. Retry freely while the queue is shallow; stop once it starts filling. |
1 |
Never abort early. A receiver that accepts but never answers holds the sender for the full retry budget. |
Authentication¶
type selects the scheme, and each scheme's fields live in its own sub-table — the same
discriminator-plus-named-section shape [analytics] uses for enabled_publishers /
[analytics.publishers.<name>]. Only the sub-table matching type is read; the others are ignored
entirely, so leaving a populated [.basic] behind while switching to bearer cannot send the wrong
credential.
type |
Sub-table | Required fields | Header sent |
|---|---|---|---|
"none" |
— | — | (none: the default, and what an omitted type resolves to) |
"bearer" |
[traffic_logging.http.auth.bearer] |
token |
Authorization: Bearer <token> |
"basic" |
[traffic_logging.http.auth.basic] |
username, password |
Authorization: Basic <base64(user:pass)> |
"header" |
[traffic_logging.http.auth.header] |
name, value |
<name>: <value> |
Exactly one header is added to the POST. There is no way to combine two schemes. A receiver needing
more than one header should sit behind a collector that adds the rest. A missing required field for the
selected type is a startup error, not a silently unauthenticated POST carrying request bodies. The
type value is matched case-insensitively and trimmed.
For a receiver that reads a bearer token,
For a collector whose HTTP input takes basic auth,
For receivers whose scheme is not Bearer — Splunk HEC expects Authorization: Splunk <token>,
which bearer cannot express — or that authenticate on a non-Authorization header entirely.
# Splunk HEC
[traffic_logging.http]
endpoint = "https://splunk.example.com:8088/services/collector/raw?sourcetype=wso2:gateway:traffic"
[traffic_logging.http.auth]
type = "header"
[traffic_logging.http.auth.header]
name = "Authorization"
value = 'Splunk {{ file "/secrets/gateway-runtime/hec-token" }}'
Never inline a credential¶
Supply every secret through a config-interpolation token, which the policy engine resolves at load time and never logs:
| Token | Resolves from |
|---|---|
{{ env "VAR_NAME" }} |
An environment variable — inject it from a Kubernetes Secret via envFrom/env. |
{{ file "/path/to/secret" }} |
A file's contents — mount a Secret as a volume. Reads are restricted to /etc/gateway-runtime and /secrets/gateway-runtime by default (override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST). |
The Helm chart enforces this
config.toml is rendered into a ConfigMap, not a Secret: it is readable by anything with
configmap read access in the namespace, stored unencrypted in etcd by default, and echoed by
helm get values. The chart therefore fails templating if auth.bearer.token,
auth.basic.password, or auth.header.value holds a literal value, and names the offending field.
auth.header.value accepts a literal prefix before the token (so Splunk {{ file "..." }} works),
but the credential itself must come from a token.
For local development only, set
gateway.config.traffic_logging.http.auth.allow_plaintext_credentials: true to opt out.
TLS and mTLS¶
[traffic_logging.http.tls] is the transport layer and is independent of
[traffic_logging.http.auth]: the client certificate proves who is connecting, the auth block proves
who is making the request. Use either alone, or both together. mTLS does not suppress the auth
header, and type = "none" with a client certificate is a perfectly normal mTLS-only setup.
| Parameter | Type | Default | Description |
|---|---|---|---|
ca_file |
string | "" |
PEM bundle used to verify the receiver's certificate. Empty means the system trust store. Correct for a public SaaS receiver, usually wrong for an internal collector fronted by a private CA. |
cert_file |
string | "" |
Client certificate (PEM) for mTLS. |
key_file |
string | "" |
Client private key (PEM) for mTLS. |
insecure_skip_verify |
boolean | false |
Disables verification of the receiver's certificate. When on, startup logs a warning naming the endpoint, because it exposes every logged request/response body to anyone able to intercept the connection. |
cert_file and key_file are both or neither — setting one without the other is a startup error
rather than a silently unauthenticated connection. All referenced material is read and parsed at
startup, so a wrong path, an unusable CA bundle, or a mismatched key pair fails immediately instead of
at the first delivery.
[traffic_logging.http]
endpoint = "https://collector.observability.svc:8443/ingest"
# mTLS only — the client certificate is the credential.
[traffic_logging.http.auth]
type = "none"
[traffic_logging.http.tls]
ca_file = "/secrets/gateway-runtime/collector-ca.crt"
cert_file = "/secrets/gateway-runtime/traffic-log-client.crt"
key_file = "/secrets/gateway-runtime/traffic-log-client.key"
To combine mTLS with a bearer token — common where the mesh authenticates the workload and the log
store authorizes the tenant — keep the tls block above and add:
[traffic_logging.http.auth]
type = "bearer"
[traffic_logging.http.auth.bearer]
token = '{{ file "/secrets/gateway-runtime/traffic-log-token" }}'
The whole tls block is ignored when the endpoint is plaintext http://, since there is no handshake
to configure.
Redaction and payload control¶
masked_headersredacts matching header values (case-insensitive) to****in the loggedrequestHeaders/responseHeadersmaps, in every sink. This is applied output-side by Traffic Logging only — other consumers of the collector (such as Moesif) are unaffected and receive unmasked headers.max_payload_sizetruncates the request/response body written to each line. Set to0(default) for no limit. Applied output-side only — the collector still captures the full body.exclude_fieldsdrops named fields from the emitted line entirely, on top of the toggles above.
Field exclusion¶
exclude_fields accepts top-level keys (e.g. "latencies", "requestHeaders") or dotted paths of
arbitrary depth into nested JSON objects, for example:
- Sub-keys immediately under
requestHeaders/responseHeadersmatch case-insensitively (HTTP header names); every other path segment matches case-sensitively. - A path can reach into a nested object produced by a
propertiesexpression that resolves to a map, for exampleproperties.claims.internal_debug. - A path that reaches past a string value (one dot too many into a single header, or into
requestBody) is a no-op for that entry rather than an error.
Note
exclude_fields only trims what the request_*/response_* toggles already include — it is not a
standalone switch. Setting only exclude_fields, with every toggle left at its false default,
still logs no headers or bodies at all.
Ignoring paths¶
collector.ignore_path_prefixes suppresses the analytics event — and therefore the traffic-log line —
for matching paths, such as health checks or metrics scrapes:
This is enforced by Envoy itself via an access-log filter, so the policy-engine never even receives the request for a matching path.
Custom properties¶
[traffic_logging.properties] adds extra key → value pairs under a top-level properties object in
the log line. A value prefixed $ctx: is evaluated as a CEL expression against the collected request
context; any other value is emitted as a literal string.
[traffic_logging.properties]
env = "prod"
apiName = "$ctx:api.name"
status = "$ctx:response.status"
subject = "$ctx:auth.subject != '' ? auth.subject : 'anonymous'"
tenant = "$ctx:'tenant' in auth.property ? auth.property['tenant'] : ''"
appId = "$ctx:'applicationId' in metadata ? metadata['applicationId'] : ''"
Available variables:
| Namespace | Variables |
|---|---|
| Request | request.path, request.method, request.id, request.header['<name>'] (masked per masked_headers) |
| Response | response.status, response.header['<name>'] (masked per masked_headers) |
| API | api.id, api.name, api.version, api.context, api.kind, project.id |
| Target | target.statusCode, target.destination |
| Application | application.id, application.name, application.owner, application.keyType — populated only when an auth policy that stamps application identity (currently api-key-auth) ran |
| Auth | auth.subject, auth.type, auth.issuer, auth.credential_id, auth.token_id, auth.audience (list), auth.scopes (list), auth.property['<claim>'] (map), auth.authenticated, auth.authorized |
| Generic metadata | metadata['<key>'] — any key any policy (including third-party/Python policies) has written into shared request metadata |
Note
Unlike headers, metadata['<key>'] has no masking configuration. Shared request metadata is a
generic, schema-less bag that any policy can write to, so any value referenced from properties is
emitted verbatim. Avoid referencing metadata keys that may contain sensitive values.
A $ctx: expression that references a variable this surface doesn't expose (for example, a typo) fails
to compile when the gateway starts, is logged once, and that property is permanently omitted from every
line. It does not cause requests to fail.
Example log line¶
{
"timestamp": "2026-07-10T09:12:33.482Z",
"correlationId": "5f6b6e2a-2f38-4b7a-9c2f-6b6a2f384b7a",
"status": 200,
"api": {
"id": "01998f3e-...",
"name": "Weather-API",
"version": "1.0.0",
"context": "/weather",
"kind": "REST"
},
"operation": {
"method": "GET",
"path": "/current"
},
"target": {
"statusCode": 200,
"destination": "https://backend.example.com/current"
},
"application": {
"id": "app-123",
"name": "Mobile App",
"owner": "jdoe",
"keyType": "PRODUCTION"
},
"client": {
"ip": "203.0.113.10",
"userAgent": "curl/8.4.0"
},
"latencies": {
"durationUs": 18342,
"requestMediationLatencyUs": 412,
"responseMediationLatencyUs": 298,
"backendLatencyUs": 17102
},
"requestHeaders": {
"authorization": "****",
"user-agent": "curl/8.4.0"
},
"responseHeaders": {
"content-type": "application/json"
},
"properties": {
"env": "prod",
"apiName": "Weather-API",
"subject": "[email protected]"
}
}
Fields whose values are entirely empty (for example application on an unauthenticated request) are
omitted from the line rather than emitted as {}.
The same line is written to every configured sink, so with outputs = ["file", "http"] one request
produces one line in the file and one line in the batch sent to the receiver.
Monitoring the sinks¶
The policy-engine exposes per-sink Prometheus metrics on its metrics port ([policy_engine.metrics],
port 9003 by default). Every series is labeled sink — stdout, file, or http.
| Metric | Type | Labels | Description |
|---|---|---|---|
policy_engine_traffic_log_written_total |
counter | sink |
Lines successfully written. |
policy_engine_traffic_log_dropped_total |
counter | sink, reason |
Lines lost. This is the series to alert on. |
policy_engine_traffic_log_write_errors_total |
counter | sink, code |
Delivery errors, by HTTP status (http) or error class (write, rotate, transport). |
policy_engine_traffic_log_queue_depth |
gauge | sink |
Lines currently queued for delivery (http only). |
policy_engine_traffic_log_queue_capacity |
gauge | sink |
Configured queue capacity (http only), so an alert can compare depth as a ratio rather than a meaningless absolute threshold. |
policy_engine_traffic_log_flush_duration_seconds |
histogram | sink |
Duration of a batch delivery attempt (http only). |
dropped_total reasons:
| Reason | Sink | Meaning |
|---|---|---|
queue_full |
http |
The bounded queue had no room; dropped per on_queue_full. |
send_failed |
http |
Retries exhausted — the receiver is failing or unreachable. |
backpressure |
http |
Retries abandoned to resume draining, per retry_abort_queue_ratio. The receiver is slow, not broken. |
write_failed |
stdout, file |
A local write returned an error. |
rotate_failed |
file |
Rotation failed, so the line that triggered it was not written. |
Counters for each configured sink are materialized at zero on startup, so a healthy gateway reports
dropped_total 0 rather than an absent series — which would leave a dashboard reading "No data" and
make "nothing was dropped" indistinguishable from "the metrics path is broken".
Useful queries:
# Are we losing traffic-log lines at all?
sum by (sink, reason) (rate(policy_engine_traffic_log_dropped_total[5m])) > 0
# Queue pressure as a fraction of capacity (alert above ~0.5)
sum by (sink) (policy_engine_traffic_log_queue_depth)
/ sum by (sink) (policy_engine_traffic_log_queue_capacity)
# Delivery success ratio
sum(rate(policy_engine_traffic_log_written_total[5m]))
/ (sum(rate(policy_engine_traffic_log_written_total[5m]))
+ sum(rate(policy_engine_traffic_log_dropped_total[5m])))
Tip
When aggregating across sinks, always use sum by (sink) rather than a bare sum(). With two sinks
configured, one request produces one write per sink, so a bare sum() of
written_total reads as double the request rate.
Kubernetes / Helm¶
The chart renders the whole feature from .Values.gateway.config — [collector],
[traffic_logging] including outputs, shutdown_timeout, properties, and the file, http,
http.auth and http.tls sub-tables.
gateway:
config:
collector:
request_body: true
response_body: true
request_headers: true
response_headers: true
ignore_path_prefixes: ["/health", "/metrics"]
traffic_logging:
enabled: true
outputs: ["file", "http"]
request_headers: true
request_body: true
response_headers: true
response_body: true
masked_headers: ["authorization", "cookie", "set-cookie", "x-api-key"]
max_payload_size: 4096
shutdown_timeout: "5s"
properties:
env: "prod"
apiName: "$ctx:api.name"
file:
path: /var/log/wso2/traffic/traffic.log
max_size_mb: 100
http:
endpoint: https://splunk.example.com:8088/services/collector/raw
queue_capacity: 10000
retry_abort_queue_ratio: 0.5
auth:
type: header
header:
name: Authorization
value: 'Splunk {{ file "/secrets/gateway-runtime/hec-token" }}'
tls:
ca_file: /secrets/gateway-runtime/collector-ca.crt
Mount the referenced secrets yourself (as a Secret volume under /secrets/gateway-runtime, or as
environment variables for {{ env }} tokens) — the chart renders the reference, never the value.
Note
Numeric and duration keys are rendered whenever the key is present, including an explicit 0,
so a mistaken queue_capacity: 0 reaches validation and is rejected at startup instead of being
silently replaced by the default.
Failure semantics and troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Pod fails to start: path is required when the "file" sink is selected |
outputs contains "file" but file.path is unset. |
Set an absolute file.path. |
Pod fails to start: has permissions 0644, which allow group/other access |
The log file already existed with a permissive mode. | chmod 600 <path> (or remove the file) and restart. |
Pod fails to start: endpoint uses plaintext http:// but allow_insecure_transport is false |
Plaintext endpoint. | Use https://, or set allow_insecure_transport = true for a trusted local collector. |
Templating fails: holds a literal value, which would be written in plaintext into the gateway ConfigMap |
A credential was inlined in Helm values. | Use {{ env }} / {{ file }}, as described in Never inline a credential. |
Templating fails: is not under ... trafficLogVolume.mountPath |
file.path sits outside the mounted volume. |
Move the path under mountPath, change mountPath, or set trafficLogVolume.enabled: false if you mount your own volume. |
| No lines at all, but requests are succeeding | traffic_logging.enabled is false, or the path matches collector.ignore_path_prefixes. |
Check both. |
| Lines appear but headers/bodies are missing | The matching [collector] capture flag is off, or exclude_fields drops them. |
Enable the [collector] flag; the traffic_logging toggle alone is a no-op. |
dropped_total{reason="queue_full"} climbing |
The receiver cannot keep up with the event rate. | Raise queue_capacity, raise batch_max_events, or scale the receiver. |
dropped_total{reason="backpressure"} climbing |
The receiver accepts but responds slowly, and retries were abandoned to keep draining. | Fix receiver latency; or raise retry_abort_queue_ratio to favor delivery over drain (at the cost of newer events). |
dropped_total{reason="send_failed"} climbing |
Retries exhausted — the receiver is failing or unreachable. | Check write_errors_total{sink="http"} for the status code, then the endpoint, auth material, and TLS trust. |
Lines lost after a restart, file sink |
Buffering only applies to http; file and stdout write straight to their descriptor. |
Nothing to tune — check dropped_total instead. |
traffic_logging.shutdown_timeout (default "5s") bounds the flush of buffering sinks on SIGTERM.
Only http buffers; stdout and file have nothing to flush.