This is an automated email from the ASF dual-hosted git repository.
nic443 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new ddddeafc2 feat: add core.response.get_response_source() API for
response origin classification (#13224)
ddddeafc2 is described below
commit ddddeafc21d082f2c7090819d30b445c1122bd77
Author: Nic <[email protected]>
AuthorDate: Thu Apr 16 10:14:11 2026 +0800
feat: add core.response.get_response_source() API for response origin
classification (#13224)
---
apisix/core/response.lua | 75 ++++++
apisix/init.lua | 8 +
apisix/plugins/ai-proxy/base.lua | 3 +
apisix/plugins/opentelemetry.lua | 14 +-
apisix/plugins/prometheus/exporter.lua | 4 +
apisix/plugins/zipkin.lua | 6 +-
t/core/response-source.t | 389 ++++++++++++++++++++++++++++++
t/plugin/opentelemetry4-bugfix-pb-state.t | 1 +
t/plugin/prometheus.t | 2 +-
t/plugin/prometheus2.t | 8 +-
t/plugin/prometheus3.t | 2 +-
t/plugin/prometheus4.t | 2 +-
12 files changed, 500 insertions(+), 14 deletions(-)
diff --git a/apisix/core/response.lua b/apisix/core/response.lua
index ffc692eb8..55135fd5b 100644
--- a/apisix/core/response.lua
+++ b/apisix/core/response.lua
@@ -40,6 +40,7 @@ local str_sub = string.sub
local tonumber = tonumber
local clear_tab = require("table.clear")
local pairs = pairs
+local ngx_var = ngx.var
local _M = {version = 0.1}
@@ -87,6 +88,10 @@ function resp_exit(code, ...)
end
if code then
+ local ctx = ngx.ctx.api_ctx
+ if ctx and not ctx._resp_source then
+ ctx._resp_source = "apisix"
+ end
if code >= 400 then
tracer.finish_all(ngx.ctx, tracer.status.ERROR, "response code "
.. code)
end
@@ -159,6 +164,76 @@ function _M.get_upstream_status(ctx)
end
+--- Explicitly set the response source for this request.
+-- Use this in plugins that bypass NGINX proxy (e.g. ai-proxy) to indicate
+-- whether the response originated from the upstream service.
+-- Must be called before core.response.exit() since exit() won't override
+-- an already-set source.
+function _M.set_response_source(ctx, source)
+ if ctx then
+ ctx._resp_source = source
+ end
+end
+
+
+--- Extract the last non-comma token from a comma/space-separated NGINX
+-- upstream variable string (e.g. "-, 0.002" → "0.002", "0, 0" → "0").
+-- Exported for testability; not part of the public API.
+function _M.get_last_upstream_token(s)
+ if not s then
+ return nil
+ end
+ local last
+ for token in s:gmatch("[^%s,]+") do
+ last = token
+ end
+ return last
+end
+
+
+--- Get the source of the current response.
+--
+-- @function core.response.get_response_source
+-- @tparam table ctx The APISIX request context (api_ctx).
+-- @treturn string One of:
+-- "apisix" — response generated by APISIX Lua code (e.g. route not found,
plugin rejection)
+-- "nginx" — error generated by NGINX proxy module (e.g. connection
refused, timeout)
+-- "upstream" — real HTTP response returned by the upstream service
+function _M.get_response_source(ctx)
+ if not ctx then
+ return "apisix"
+ end
+
+ -- Priority 1: explicitly marked by core.response.exit() or
set_response_source()
+ if ctx._resp_source then
+ return ctx._resp_source
+ end
+
+ -- Priority 2: request was proxied — inspect $upstream_header_time to
+ -- determine if the upstream actually sent response headers.
+ --
+ -- Use ngx.var directly (not ctx.var) because lua-var-nginx-module's FFI
+ -- path clamps header_time from -1 to 0 via ngx_max(ms, 0), losing the
+ -- "-" sentinel that NGINX uses to indicate "no response headers received"
+ -- (e.g. connection refused, connect timeout). ngx.var preserves "-".
+ if ctx._apisix_proxied then
+ local header_time = ngx_var.upstream_header_time
+ if header_time then
+ local last = _M.get_last_upstream_token(header_time)
+ if last and last ~= "-" then
+ ctx._resp_source = "upstream"
+ return "upstream"
+ end
+ end
+ ctx._resp_source = "nginx"
+ return "nginx"
+ end
+
+ -- Fallback: never reached proxy_pass
+ return "apisix"
+end
+
+
function _M.clear_header_as_body_modified()
ngx.header.content_length = nil
-- in case of upstream content is compressed content
diff --git a/apisix/init.lua b/apisix/init.lua
index b8ce4a11d..639709cd3 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -589,6 +589,14 @@ function _M.handle_upstream(api_ctx, route,
enable_websocket)
-- run the before_proxy method in access phase first to avoid always
reinit request
common_phase("before_proxy")
+ -- Mark that the request will be proxied to upstream via NGINX.
+ -- Must be set after before_proxy plugins (which may call
core.response.exit())
+ -- and before proxy_pass dispatch, so the log phase can distinguish
+ -- NGINX proxy errors from upstream responses.
+ if not api_ctx._resp_source then
+ api_ctx._apisix_proxied = true
+ end
+
local up_scheme = api_ctx.upstream_scheme
if up_scheme == "grpcs" or up_scheme == "grpc" then
stash_ngx_ctx()
diff --git a/apisix/plugins/ai-proxy/base.lua b/apisix/plugins/ai-proxy/base.lua
index 3cf054e45..c893c4ec4 100644
--- a/apisix/plugins/ai-proxy/base.lua
+++ b/apisix/plugins/ai-proxy/base.lua
@@ -203,6 +203,9 @@ function _M.before_proxy(conf, ctx, on_error)
return transport_http.handle_error(transport_err)
end
+ -- Upstream responded — mark source before any early returns
+ core.response.set_response_source(ctx, "upstream")
+
if res.status == 429 or (res.status >= 500 and res.status < 600)
then
return res.status
end
diff --git a/apisix/plugins/opentelemetry.lua b/apisix/plugins/opentelemetry.lua
index bf366936d..9185d7b20 100644
--- a/apisix/plugins/opentelemetry.lua
+++ b/apisix/plugins/opentelemetry.lua
@@ -461,19 +461,23 @@ end
function _M.log(conf, api_ctx)
if api_ctx.otel_context_token then
-- ctx:detach() is not necessary, because of ctx is stored in ngx.ctx
- local upstream_status = core.response.get_upstream_status(api_ctx)
+ local resp_source = core.response.get_response_source(api_ctx)
+ local status_code = ngx.status
-- get span from current context
local ctx = context:current()
local span = ctx:span()
- if upstream_status and upstream_status >= 500 then
+
+ span:set_attributes(attr.string("apisix.response_source", resp_source))
+
+ if status_code and status_code >= 500 then
span:set_status(span_status.ERROR,
- "upstream response status: " .. upstream_status)
+ resp_source .. " error: " .. status_code)
end
inject_core_spans(ctx, api_ctx, conf)
- span:set_attributes(attr.int("http.status_code", upstream_status),
- attr.int("http.response.status_code",
upstream_status))
+ span:set_attributes(attr.int("http.status_code", status_code),
+ attr.int("http.response.status_code", status_code))
update_time()
span:finish()
end
diff --git a/apisix/plugins/prometheus/exporter.lua
b/apisix/plugins/prometheus/exporter.lua
index 2d4ed346e..ce89ca033 100644
--- a/apisix/plugins/prometheus/exporter.lua
+++ b/apisix/plugins/prometheus/exporter.lua
@@ -203,6 +203,7 @@ function _M.http_init(prometheus_enabled_in_stream)
"HTTP status codes per service in APISIX",
{"code", "route", "matched_uri", "matched_host", "service",
"consumer", "node",
"request_type", "request_llm_model", "llm_model",
+ "response_source",
unpack(extra_labels("http_status"))},
status_metrics_exptime)
@@ -317,10 +318,13 @@ function _M.http_log(conf, ctx)
matched_host = ctx.curr_req_matched._host or ""
end
+ local response_source = core.response.get_response_source(ctx)
+
metrics.status:inc(1,
gen_arr(vars.status, route_id, matched_uri, matched_host,
service_id, consumer_name, balancer_ip,
vars.request_type, vars.request_llm_model, vars.llm_model,
+ response_source,
unpack(extra_labels("http_status", ctx))))
local latency, upstream_latency, apisix_latency = latency_details(ctx)
diff --git a/apisix/plugins/zipkin.lua b/apisix/plugins/zipkin.lua
index 285e0a45d..615828bc5 100644
--- a/apisix/plugins/zipkin.lua
+++ b/apisix/plugins/zipkin.lua
@@ -309,8 +309,10 @@ function _M.log(conf, ctx)
opentracing.response_span:finish(log_end_time)
end
- local upstream_status = core.response.get_upstream_status(ctx)
- opentracing.request_span:set_tag("http.status_code", upstream_status)
+ local resp_source = core.response.get_response_source(ctx)
+ local status_code = ngx.status
+ opentracing.request_span:set_tag("http.status_code", status_code)
+ opentracing.request_span:set_tag("apisix.response_source", resp_source)
opentracing.request_span:finish(log_end_time)
end
diff --git a/t/core/response-source.t b/t/core/response-source.t
new file mode 100644
index 000000000..4559595a4
--- /dev/null
+++ b/t/core/response-source.t
@@ -0,0 +1,389 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_shuffle();
+no_root_location();
+log_level("info");
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: get_response_source returns "apisix" when ctx is nil
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local source = core.response.get_response_source(nil)
+ ngx.say(source)
+ }
+ }
+--- response_body
+apisix
+
+
+
+=== TEST 2: get_response_source returns "apisix" when no flags set
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local ctx = {}
+ local source = core.response.get_response_source(ctx)
+ ngx.say(source)
+ }
+ }
+--- response_body
+apisix
+
+
+
+=== TEST 3: get_response_source returns explicit _resp_source
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local ctx = {_resp_source = "apisix"}
+ ngx.say(core.response.get_response_source(ctx))
+ ctx._resp_source = "upstream"
+ ngx.say(core.response.get_response_source(ctx))
+ ctx._resp_source = "nginx"
+ ngx.say(core.response.get_response_source(ctx))
+ }
+ }
+--- response_body
+apisix
+upstream
+nginx
+
+
+
+=== TEST 4: get_last_upstream_token: nil input
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ ngx.say(core.response.get_last_upstream_token(nil) or "nil")
+ }
+ }
+--- response_body
+nil
+
+
+
+=== TEST 5: get_last_upstream_token: single value
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ ngx.say(core.response.get_last_upstream_token("0.002"))
+ ngx.say(core.response.get_last_upstream_token("-"))
+ ngx.say(core.response.get_last_upstream_token("0"))
+ }
+ }
+--- response_body
+0.002
+-
+0
+
+
+
+=== TEST 6: get_last_upstream_token: comma-separated retries
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ -- first attempt failed, second succeeded
+ ngx.say(core.response.get_last_upstream_token("-, 0.002"))
+ -- both attempts failed
+ ngx.say(core.response.get_last_upstream_token("-, -"))
+ -- first succeeded, retry failed
+ ngx.say(core.response.get_last_upstream_token("0.002, -"))
+ }
+ }
+--- response_body
+0.002
+-
+-
+
+
+
+=== TEST 7: get_last_upstream_token: spaces around separators
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ ngx.say(core.response.get_last_upstream_token("- , 0.001"))
+ ngx.say(core.response.get_last_upstream_token("0.001 , -"))
+ ngx.say(core.response.get_last_upstream_token("- , -"))
+ }
+ }
+--- response_body
+0.001
+-
+-
+
+
+
+=== TEST 8: get_last_upstream_token: colon-separated (upstream groups)
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ -- colon separates upstream groups per NGINX docs
+ ngx.say(core.response.get_last_upstream_token("- : 0.003"))
+ ngx.say(core.response.get_last_upstream_token("0.003 : -"))
+ }
+ }
+--- response_body
+0.003
+-
+
+
+
+=== TEST 9: get_last_upstream_token: empty string
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ ngx.say(core.response.get_last_upstream_token("") or "nil")
+ }
+ }
+--- response_body
+nil
+
+
+
+=== TEST 10: get_last_upstream_token: three retries
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ ngx.say(core.response.get_last_upstream_token("-, -, 0.005"))
+ ngx.say(core.response.get_last_upstream_token("-, -, -"))
+ }
+ }
+--- response_body
+0.005
+-
+
+
+
+=== TEST 11: _resp_source takes priority over _apisix_proxied
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local ctx = {
+ _resp_source = "apisix",
+ _apisix_proxied = true,
+ }
+ local source = core.response.get_response_source(ctx)
+ ngx.say(source)
+ }
+ }
+--- response_body
+apisix
+
+
+
+=== TEST 12: set_response_source sets ctx._resp_source
+--- config
+ location = /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local ctx = {}
+ core.response.set_response_source(ctx, "upstream")
+ ngx.say(ctx._resp_source)
+ ngx.say(core.response.get_response_source(ctx))
+ }
+ }
+--- response_body
+upstream
+upstream
+
+
+
+=== TEST 13: resp_exit sets _resp_source = "apisix" for error codes
+--- config
+ location = /t {
+ access_by_lua_block {
+ ngx.ctx.api_ctx = {}
+ local core = require("apisix.core")
+ core.response.exit(403, "forbidden\n")
+ }
+ log_by_lua_block {
+ local ctx = ngx.ctx.api_ctx
+ ngx.log(ngx.INFO, "resp_source: ", ctx._resp_source or "nil")
+ }
+ }
+--- error_code: 403
+--- response_body
+forbidden
+--- error_log
+resp_source: apisix
+
+
+
+=== TEST 14: resp_exit sets _resp_source for success codes too
+--- config
+ location = /t {
+ access_by_lua_block {
+ ngx.ctx.api_ctx = {}
+ local core = require("apisix.core")
+ core.response.exit(200, "ok\n")
+ }
+ log_by_lua_block {
+ local ctx = ngx.ctx.api_ctx
+ ngx.log(ngx.INFO, "resp_source: ", ctx._resp_source or "nil")
+ }
+ }
+--- response_body
+ok
+--- error_log
+resp_source: apisix
+
+
+
+=== TEST 15: resp_exit does not override explicit set_response_source
+--- config
+ location = /t {
+ access_by_lua_block {
+ local ctx = {}
+ ngx.ctx.api_ctx = ctx
+ local core = require("apisix.core")
+ core.response.set_response_source(ctx, "upstream")
+ core.response.exit(200, "ok\n")
+ }
+ log_by_lua_block {
+ local ctx = ngx.ctx.api_ctx
+ ngx.log(ngx.INFO, "resp_source: ", ctx._resp_source or "nil")
+ }
+ }
+--- response_body
+ok
+--- error_log
+resp_source: upstream
+
+
+
+=== TEST 16: integration - upstream returns 200, response_source = "upstream"
+--- apisix_yaml
+routes:
+ -
+ uri: /hello
+ plugins:
+ serverless-pre-function:
+ phase: log
+ functions:
+ - "return function(_, ctx) ngx.log(ngx.WARN, 'resp_source:
', require('apisix.core').response.get_response_source(ctx)) end"
+ upstream:
+ nodes:
+ "127.0.0.1:1980": 1
+ type: roundrobin
+#END
+--- request
+GET /hello
+--- error_code: 200
+--- error_log
+resp_source: upstream
+
+
+
+=== TEST 17: integration - upstream connection refused, response_source =
"nginx"
+--- apisix_yaml
+routes:
+ -
+ uri: /hello
+ plugins:
+ serverless-pre-function:
+ phase: log
+ functions:
+ - "return function(_, ctx) ngx.log(ngx.WARN, 'resp_source:
', require('apisix.core').response.get_response_source(ctx)) end"
+ upstream:
+ nodes:
+ "127.0.0.1:11111": 1
+ type: roundrobin
+#END
+--- request
+GET /hello
+--- error_code: 502
+--- error_log
+resp_source: nginx
+
+
+
+=== TEST 18: integration - upstream returns 502, response_source = "upstream"
+This verifies that a real 502 from upstream is classified as "upstream", not
"nginx".
+--- apisix_yaml
+routes:
+ -
+ uri: /specific_status
+ plugins:
+ serverless-pre-function:
+ phase: log
+ functions:
+ - "return function(_, ctx) ngx.log(ngx.WARN, 'resp_source:
', require('apisix.core').response.get_response_source(ctx)) end"
+ upstream:
+ nodes:
+ "127.0.0.1:1980": 1
+ type: roundrobin
+#END
+--- request
+GET /specific_status
+--- more_headers
+X-Test-Upstream-Status: 502
+--- error_code: 502
+--- error_log
+resp_source: upstream
+
+
+
+=== TEST 19: integration - APISIX plugin rejects request, response_source =
"apisix"
+--- apisix_yaml
+routes:
+ -
+ uri: /hello
+ plugins:
+ serverless-pre-function:
+ functions:
+ - "return function() local core = require('apisix.core');
core.response.exit(403, 'rejected by plugin') end"
+ serverless-post-function:
+ phase: log
+ functions:
+ - "return function(_, ctx) ngx.log(ngx.WARN, 'resp_source:
', require('apisix.core').response.get_response_source(ctx)) end"
+ upstream:
+ nodes:
+ "127.0.0.1:1980": 1
+ type: roundrobin
+#END
+--- request
+GET /hello
+--- error_code: 403
+--- error_log
+resp_source: apisix
diff --git a/t/plugin/opentelemetry4-bugfix-pb-state.t
b/t/plugin/opentelemetry4-bugfix-pb-state.t
index bc8405df7..f522f2503 100644
--- a/t/plugin/opentelemetry4-bugfix-pb-state.t
+++ b/t/plugin/opentelemetry4-bugfix-pb-state.t
@@ -189,6 +189,7 @@ type 'opentelemetry.proto.trace.v1.TracesData' does not
exists
--- grep_error_log eval
qr/attribute (apisix|x-my).+?:.[^,]*/
--- grep_error_log_out
+attribute apisix.response_source: "upstream"
attribute apisix.route_id: "1"
attribute apisix.route_name: "route_name"
attribute x-my-header-name: "william"
diff --git a/t/plugin/prometheus.t b/t/plugin/prometheus.t
index d29406886..8edfd5299 100644
--- a/t/plugin/prometheus.t
+++ b/t/plugin/prometheus.t
@@ -394,7 +394,7 @@ passed
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="404",route="3",matched_uri="\/hello3",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model=""\}
2/
+qr/apisix_http_status\{code="404",route="3",matched_uri="\/hello3",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
2/
diff --git a/t/plugin/prometheus2.t b/t/plugin/prometheus2.t
index 9c19f0da2..cf303ce77 100644
--- a/t/plugin/prometheus2.t
+++ b/t/plugin/prometheus2.t
@@ -180,7 +180,7 @@ apikey: auth-one
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="200",route="1",matched_uri="\/hello",matched_host="",service="",consumer="jack",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model=""\}
\d+/
+qr/apisix_http_status\{code="200",route="1",matched_uri="\/hello",matched_host="",service="",consumer="jack",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
\d+/
@@ -256,7 +256,7 @@ GET /not_found
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="404",route="",matched_uri="",matched_host="",service="",consumer="",node="",request_type="traditional_http",request_llm_model="",llm_model=""\}
\d+/
+qr/apisix_http_status\{code="404",route="",matched_uri="",matched_host="",service="",consumer="",node="",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
\d+/
@@ -275,7 +275,7 @@ qr/404 Not Found/
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="404",route="9",matched_uri="\/foo\*",matched_host="foo.com",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model=""\}
\d+/
+qr/apisix_http_status\{code="404",route="9",matched_uri="\/foo\*",matched_host="foo.com",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
\d+/
@@ -294,7 +294,7 @@ qr/404 Not Found/
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="404",route="9",matched_uri="\/bar\*",matched_host="bar.com",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model=""\}
\d+/
+qr/apisix_http_status\{code="404",route="9",matched_uri="\/bar\*",matched_host="bar.com",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
\d+/
diff --git a/t/plugin/prometheus3.t b/t/plugin/prometheus3.t
index 0440b1e84..57c2f73df 100644
--- a/t/plugin/prometheus3.t
+++ b/t/plugin/prometheus3.t
@@ -270,4 +270,4 @@ opentracing
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="200",route="1",matched_uri="\/opentracing",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model=""\}
1/
+qr/apisix_http_status\{code="200",route="1",matched_uri="\/opentracing",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*"\}
1/
diff --git a/t/plugin/prometheus4.t b/t/plugin/prometheus4.t
index d30946a7b..5e2fa8b86 100644
--- a/t/plugin/prometheus4.t
+++ b/t/plugin/prometheus4.t
@@ -143,7 +143,7 @@ GET /hello
--- request
GET /apisix/prometheus/metrics
--- response_body eval
-qr/apisix_http_status\{code="200",route="10",matched_uri="\/hello",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",dummy=""\}
\d+/
+qr/apisix_http_status\{code="200",route="10",matched_uri="\/hello",matched_host="",service="",consumer="",node="127.0.0.1",request_type="traditional_http",request_llm_model="",llm_model="",response_source="[^"]*",dummy=""\}
\d+/