janiussyafiq opened a new issue, #13724:
URL: https://github.com/apache/apisix/issues/13724

   
   ## Current Behavior
   
   Since 3.16.0 every logger plugin carries an `access`-phase handler 
(`_M.access = log_util.check_and_read_req_body`, introduced in #13034). Because 
the plugin now runs in `access`, `run_plugin("access", ...)` invokes 
`meta_filter()`, which evaluates the plugin's `_meta.filter` **and caches the 
verdict for the rest of the request**.
   
   At `access` time the request has not been proxied yet: `$status` is `0` and 
`$upstream_status` is empty. This produces two distinct problems.
   
   **A. Filter conditions on response-phase variables are decided before those 
variables have values.**
   
   A filter referencing `$status` or `$upstream_status` is evaluated against 
`0` / empty, and that verdict is reused at log phase. The filter silently makes 
the wrong decision — either keeping entries it was written to drop, or dropping 
every entry.
   
   **B. `$status` is cached as `0`, corrupting the output of every plugin on 
the request.**
   
   `status` is not in `no_cacheable_var_names`, so the access-phase read stores 
`0` into `ctx.var` for the remainder of the request. Every plugin that later 
reads `$status` gets `0` — **including logger plugins that have no 
`_meta.filter` of their own**. A filter on one plugin silently corrupts a 
different plugin's log output.
   
   Neither problem emits any error or warning.
   
   **Blast radius.** In 3.17.0, 14 logger plugins carry this `access` handler, 
so any of them can trigger both problems:
   
   `clickhouse-logger`, `elasticsearch-logger`, `file-logger`, `http-logger`, 
`kafka-logger`, `loggly`, `loki-logger`, `rocketmq-logger`, 
`skywalking-logger`, `sls-logger`, `syslog`, `tcp-logger`, `tencent-cloud-cls`, 
`udp-logger`
   
   (12 assign `_M.access = log_util.check_and_read_req_body` directly; 
`elasticsearch-logger` and `tencent-cloud-cls` call 
`log_util.check_and_read_req_body` from a wrapper `_M.access`.)
   
   **First bad version: 3.16.0.** 3.15.0 behaves correctly; 3.16.0 and 3.17.0 
both reproduce. This boundary was established with the same filter and upstream 
using standalone (`APISIX_STAND_ALONE`) config rather than the Admin API steps 
below.
   
   ## Expected Behavior
   
   1. A `_meta.filter` condition on `$status` / `$upstream_status` should be 
evaluated when those variables have values, i.e. at log phase — not decided in 
`access` and cached.
   2. `$status` in `log_format` should be the real response status, never `0` 
for a request that completed.
   3. A `_meta.filter` on one plugin must never change what a different plugin 
logs.
   
   ## Error Logs
   
   None. This is the core of the problem — it fails completely silently.
   
   Across all scenarios below, `error.log` contained **zero** lines mentioning 
`vars`, `filter`, or `expression`. No `failed to run the 'vars' expression`, no 
warning of any kind. The only `[warn]`/`[error]` lines present were unrelated 
startup messages (plugin loading, a `saml-auth` load failure, and an 
http-logger TLS advisory).
   
   ## Steps to Reproduce
   
   ### Setup
   
   Two files in an empty directory named `apisix-repro` (the directory name 
fixes the container names used below):
   
   `config.yaml`
   
   ```yaml
   deployment:
     role: traditional
     role_traditional:
       config_provider: etcd
     admin:
       admin_key:
         - name: admin
           key: edd1c9f034335f136f87ad84b625c8f1
           role: admin
       allow_admin:
         - 0.0.0.0/0
     etcd:
       host:
         - "http://etcd:2379";
       prefix: /apisix
       timeout: 30
   ```
   
   `docker-compose.yml`
   
   ```yaml
   services:
     apisix:
       image: apache/apisix:3.17.0-debian
       volumes:
         - ./config.yaml:/usr/local/apisix/conf/config.yaml:ro
       depends_on: [etcd]
       ports:
         - "9080:9080"
         - "9180:9180"
   
     etcd:
       image: bitnamilegacy/etcd:3.5.11
       environment:
         ETCD_ENABLE_V2: "true"
         ALLOW_NONE_AUTHENTICATION: "yes"
         ETCD_ADVERTISE_CLIENT_URLS: "http://etcd:2379";
         ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379";
   
     httpbin:
       image: mccutchen/go-httpbin
   ```
   
   ```sh
   docker compose up -d
   sleep 15
   ```
   
   One route, used by every scenario:
   
   ```sh
   curl -s -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '{
     "uri": "/status/*",
     "upstream": {"type":"roundrobin","nodes":{"httpbin:8080":1}}
   }'
   ```
   
   The same three requests are sent in every scenario:
   
   ```sh
   curl -s -o /dev/null -w 'gw404 -> %{http_code}\n' 
http://127.0.0.1:9080/not-routed -H 'X-Case: gw404'
   curl -s -o /dev/null -w 'up200 -> %{http_code}\n' 
http://127.0.0.1:9080/status/200 -H 'X-Case: up200'
   curl -s -o /dev/null -w 'up404 -> %{http_code}\n' 
http://127.0.0.1:9080/status/404 -H 'X-Case: up404'
   sleep 3
   ```
   
   `gw404` is a gateway-generated 404 that never reaches an upstream, so its 
`$upstream_status` is empty. `up200` and `up404` are proxied.
   
   ### Scenario A — filter on `$status` / `$upstream_status` makes the wrong 
decision
   
   The filter says: drop an entry when the status is 404 **and** no upstream 
was reached — i.e. drop gateway-generated 404s, keep upstream 404s.
   
   ```sh
   curl -s -X PUT http://127.0.0.1:9180/apisix/admin/global_rules/1 \
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '{
     "plugins": {
       "file-logger": {
         "path": "/usr/local/apisix/logs/a.log",
         "log_format": {
           "case": "$http_x_case", "uri": "$uri",
           "status": "$status", "upstream_status": "$upstream_status"
         },
         "_meta": {
           "filter": [["!AND", ["status", "==", 404], ["upstream_status", "!", 
"~~", "."]]]
         }
       }
     }
   }'
   sleep 3
   docker exec apisix-repro-apisix-1 sh -c 'cat /usr/local/apisix/logs/a.log'
   ```
   
   ```
   {"uri":"/not-routed","status":0,"case":"gw404"}
   
{"uri":"/status/200","route_id":"1","upstream_status":"200","status":0,"case":"up200"}
   
{"uri":"/status/404","route_id":"1","upstream_status":"404","status":0,"case":"up404"}
   ```
   
   `gw404` — the single entry this filter exists to drop — was kept, because 
`status == 404` was evaluated as `0 == 404`. Every entry also has `status: 0`, 
though the requests returned 404/200/404.
   
   ### Scenario B — a filter on one plugin corrupts a different plugin
   
   The filter moves to `http-logger`. `file-logger` has **no `_meta.filter` at 
all**.
   
   ```sh
   curl -s -X PUT http://127.0.0.1:9180/apisix/admin/global_rules/1 \
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '{
     "plugins": {
       "http-logger": {
         "uri": "http://httpbin:8080/post";,
         "batch_max_size": 1, "inactive_timeout": 1,
         "_meta": {
           "filter": [["!AND", ["status", "==", 404], ["upstream_status", "!", 
"~~", "."]]]
         }
       },
       "file-logger": {
         "path": "/usr/local/apisix/logs/b.log",
         "log_format": {
           "case": "$http_x_case", "uri": "$uri",
           "status": "$status", "upstream_status": "$upstream_status"
         }
       }
     }
   }'
   sleep 3
   docker exec apisix-repro-apisix-1 sh -c 'cat /usr/local/apisix/logs/b.log'
   ```
   
   ```
   {"uri":"/not-routed","status":0,"case":"gw404"}
   
{"uri":"/status/200","route_id":"1","upstream_status":"200","status":0,"case":"up200"}
   
{"uri":"/status/404","route_id":"1","upstream_status":"404","status":0,"case":"up404"}
   ```
   
   `file-logger` is unfiltered, yet all of its `status` values are `0`.
   
   **Control** — same config with `_meta.filter` removed from `http-logger`, 
everything else identical:
   
   ```sh
   curl -s -X PUT http://127.0.0.1:9180/apisix/admin/global_rules/1 \
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '{
     "plugins": {
       "http-logger": {
         "uri": "http://httpbin:8080/post";,
         "batch_max_size": 1, "inactive_timeout": 1
       },
       "file-logger": {
         "path": "/usr/local/apisix/logs/c.log",
         "log_format": {
           "case": "$http_x_case", "uri": "$uri",
           "status": "$status", "upstream_status": "$upstream_status"
         }
       }
     }
   }'
   sleep 3
   docker exec apisix-repro-apisix-1 sh -c 'cat /usr/local/apisix/logs/c.log'
   ```
   
   ```
   {"uri":"/not-routed","status":404,"case":"gw404"}
   
{"uri":"/status/200","route_id":"1","upstream_status":"200","status":200,"case":"up200"}
   
{"uri":"/status/404","route_id":"1","upstream_status":"404","status":404,"case":"up404"}
   ```
   
   (JSON key order within each line varies between runs; the values are what 
matter.)
   
   Statuses are correct. The only difference between the two runs is the 
presence of a `_meta.filter` on a **different** plugin.
   
   ### Scenario C — a filter on `$upstream_status` drops everything
   
   Keep only requests that reached an upstream:
   
   ```sh
   curl -s -X PUT http://127.0.0.1:9180/apisix/admin/global_rules/1 \
     -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '{
     "plugins": {
       "file-logger": {
         "path": "/usr/local/apisix/logs/d.log",
         "log_format": {
           "case": "$http_x_case", "uri": "$uri",
           "status": "$status", "upstream_status": "$upstream_status"
         },
         "_meta": {"filter": [["upstream_status", "~~", "^[0-9]+"]]}
       }
     }
   }'
   sleep 3
   docker exec apisix-repro-apisix-1 sh -c 'cat /usr/local/apisix/logs/d.log'
   ```
   
   ```
   (file is empty — nothing logged at all)
   ```
   
   Two of the three requests were proxied with `$upstream_status` of `200` and 
`404`, but at `access` time `$upstream_status` was empty for all of them, so 
the filter dropped everything. This scenario isolates problem A from problem B: 
the filter never reads `$status`, so no `status` corruption is involved — 
logging simply stops, with no error anywhere.
   
   ### Cleanup
   
   ```sh
   docker compose down -v
   ```
   
   ## Environment
   
   - APISIX version (run `apisix version`): **3.17.0** 
(`apache/apisix:3.17.0-debian`); also reproduced on 3.16.0, not reproducible on 
3.15.0
   - Operating system (run `uname -a`): `Linux 3e1fdcdbc403 
7.0.11-orbstack-00360-gc9bc4d96ac70 #1 SMP PREEMPT Thu Jun 4 16:40:25 UTC 2026 
aarch64 GNU/Linux`
   - OpenResty / Nginx version (run `openresty -V` or `nginx -V`): 
`openresty/1.29.2.4`, built by gcc 12.2.0 (Debian 12.2.0-14+deb12u1)
   - etcd version, if relevant: `bitnamilegacy/etcd:3.5.11` (the image used by 
`apache/apisix-docker/example`)
   - APISIX Dashboard version, if relevant: n/a
   - Plugin runner version, for issues related to plugin runners: n/a
   - LuaRocks version, for installation issues (run `luarocks --version`): n/a 
(official Docker image)
   
   ## Root cause
   
   Two independent caches combine to produce this.
   
   **1. The filter verdict is cached from the access phase.** 
`apisix/plugin.lua`:
   
   ```lua
   local function meta_filter(ctx, plugin_name, plugin_conf)
       local filter = plugin_conf._meta and plugin_conf._meta.filter
       if not filter then
           return true
       end
   
       local match_cache_key =
           ctx.conf_type .. "#" .. ctx.conf_id .. "#"
               .. ctx.conf_version .. "#" .. plugin_name .. 
"#meta_filter_matched"
       if ctx[match_cache_key] ~= nil then
           return ctx[match_cache_key]      -- access-phase verdict, reused at 
log phase
       end
   ```
   
   Before #13034 loggers had no `access` handler, so `meta_filter` first ran at 
log phase, when response variables were populated. Now the access-phase call 
populates the cache and the log-phase call returns it unchanged. This is 
problem A.
   
   **2. `status` is cached into `ctx.var`.** `apisix/core/ctx.lua`:
   
   ```lua
   local no_cacheable_var_names = {
       -- var.args should not be cached as it can be changed via set_uri_args
       args = true,
       is_args = true,
   }
   ```
   
   `status` is not listed, so the access-phase read of `$status` is cached by:
   
   ```lua
   if val ~= nil and not no_cacheable_var_names[key] then
       t._cache[key] = val
   end
   ```
   
   At access phase `$status` is `0` — not `nil` — so it passes the guard and is 
cached. Every later reader of `$status` on that request gets `0`. Because 
`ctx.var` is shared across all plugins on the request, this leaks into plugins 
that never had a filter. This is problem B, and it is why the control in 
Scenario B isolates the cause so cleanly.
   
   The same guard explains why `$upstream_status` stays correct in Scenario A: 
at access phase it is `nil`, so it is never cached, and the log phase re-reads 
the real value. `$status` is corrupted precisely because `0` is a value rather 
than an absence.
   
   The two problems need fixes in different places (`plugin.lua`'s meta_filter 
caching vs. `no_cacheable_var_names` in `core/ctx.lua`), so a fix addressing 
only one would leave the other reproducible.
   
   Note that this re-introduces the class of problem previously addressed in 
#8162 / #8256.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to