Files
2026-07-13 12:32:21 +08:00

610 lines
26 KiB
Lua

local balancer = require "kong.runloop.balancer"
local yield = require("kong.tools.yield").yield
local wasm = require "kong.plugins.prometheus.wasmx"
local kong = kong
local ngx = ngx
local get_phase = ngx.get_phase
local lower = string.lower
local ngx_timer_pending_count = ngx.timer.pending_count
local ngx_timer_running_count = ngx.timer.running_count
local get_all_upstreams = balancer.get_all_upstreams
if not balancer.get_all_upstreams then -- API changed since after Kong 2.5
get_all_upstreams = require("kong.runloop.balancer.upstreams").get_all_upstreams
end
local CLUSTERING_SYNC_STATUS = require("kong.constants").CLUSTERING_SYNC_STATUS
local stream_available, stream_api = pcall(require, "kong.tools.stream_api")
local role = kong.configuration.role
local KONG_LATENCY_BUCKETS = { 1, 2, 5, 7, 10, 15, 20, 30, 50, 75, 100, 200, 500, 750, 1000, 3000, 6000 }
local UPSTREAM_LATENCY_BUCKETS = { 25, 50, 80, 100, 250, 400, 700, 1000, 2000, 5000, 10000, 30000, 60000 }
local AI_LLM_PROVIDER_LATENCY_BUCKETS = { 250, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 10000, 30000, 60000 }
local IS_PROMETHEUS_ENABLED = false
local export_upstream_health_metrics = false
local metrics = {}
-- prometheus.lua instance
local prometheus
local node_id = kong.node.get_id()
-- use the same counter library shipped with Kong
package.loaded['prometheus_resty_counter'] = require("resty.counter")
local kong_subsystem = ngx.config.subsystem
local http_subsystem = kong_subsystem == "http"
local function init()
local shm = "prometheus_metrics"
if not ngx.shared[shm] then
kong.log.err("prometheus: ngx shared dict 'prometheus_metrics' not found")
return
end
prometheus = require("kong.plugins.prometheus.prometheus").init(shm, "kong_")
-- global metrics
metrics.connections = prometheus:gauge("nginx_connections_total",
"Number of connections by subsystem",
{"node_id", "subsystem", "state"},
prometheus.LOCAL_STORAGE)
metrics.nginx_requests_total = prometheus:gauge("nginx_requests_total",
"Number of requests total", {"node_id", "subsystem"},
prometheus.LOCAL_STORAGE)
metrics.timers = prometheus:gauge("nginx_timers",
"Number of nginx timers",
{"state"},
prometheus.LOCAL_STORAGE)
metrics.db_reachable = prometheus:gauge("datastore_reachable",
"Datastore reachable from Kong, " ..
"0 is unreachable",
nil,
prometheus.LOCAL_STORAGE)
if role == "data_plane" then
metrics.cp_connected = prometheus:gauge("control_plane_connected",
"Kong connected to control plane, " ..
"0 is unconnected",
nil,
prometheus.LOCAL_STORAGE)
end
metrics.node_info = prometheus:gauge("node_info",
"Kong Node metadata information",
{"node_id", "version"},
prometheus.LOCAL_STORAGE)
metrics.node_info:set(1, {node_id, kong.version})
-- only export upstream health metrics in traditional mode and data plane
if role ~= "control_plane" then
metrics.upstream_target_health = prometheus:gauge("upstream_target_health",
"Health status of targets of upstream. " ..
"States = healthchecks_off|healthy|unhealthy|dns_error, " ..
"value is 1 when state is populated.",
{"upstream", "target", "address", "state", "subsystem"},
prometheus.LOCAL_STORAGE)
end
local memory_stats = {}
memory_stats.worker_vms = prometheus:gauge("memory_workers_lua_vms_bytes",
"Allocated bytes in worker Lua VM",
{"node_id", "pid", "kong_subsystem"},
prometheus.LOCAL_STORAGE)
memory_stats.shms = prometheus:gauge("memory_lua_shared_dict_bytes",
"Allocated slabs in bytes in a shared_dict",
{"node_id", "shared_dict", "kong_subsystem"},
prometheus.LOCAL_STORAGE)
memory_stats.shm_capacity = prometheus:gauge("memory_lua_shared_dict_total_bytes",
"Total capacity in bytes of a shared_dict",
{"node_id", "shared_dict", "kong_subsystem"},
prometheus.LOCAL_STORAGE)
local res = kong.node.get_memory_stats()
for shm_name, value in pairs(res.lua_shared_dicts) do
memory_stats.shm_capacity:set(value.capacity, { node_id, shm_name, kong_subsystem })
end
metrics.memory_stats = memory_stats
-- per service/route
if http_subsystem then
metrics.status = prometheus:counter("http_requests_total",
"HTTP status codes per consumer/service/route in Kong",
{"service", "route", "code", "source", "workspace", "consumer"})
else
metrics.status = prometheus:counter("stream_sessions_total",
"Stream status codes per service/route in Kong",
{"service", "route", "code", "source", "workspace"})
end
metrics.kong_latency = prometheus:histogram("kong_latency_ms",
"Latency added by Kong and enabled plugins " ..
"for each service/route in Kong",
{"service", "route", "workspace"},
KONG_LATENCY_BUCKETS)
metrics.upstream_latency = prometheus:histogram("upstream_latency_ms",
"Latency added by upstream response " ..
"for each service/route in Kong",
{"service", "route", "workspace"},
UPSTREAM_LATENCY_BUCKETS)
if http_subsystem then
metrics.total_latency = prometheus:histogram("request_latency_ms",
"Total latency incurred during requests " ..
"for each service/route in Kong",
{"service", "route", "workspace"},
UPSTREAM_LATENCY_BUCKETS)
else
metrics.total_latency = prometheus:histogram("session_duration_ms",
"latency incurred in stream session " ..
"for each service/route in Kong",
{"service", "route", "workspace"},
UPSTREAM_LATENCY_BUCKETS)
end
if http_subsystem then
metrics.bandwidth = prometheus:counter("bandwidth_bytes",
"Total bandwidth (ingress/egress) " ..
"throughput in bytes",
{"service", "route", "direction", "workspace","consumer"})
else -- stream has no consumer
metrics.bandwidth = prometheus:counter("bandwidth_bytes",
"Total bandwidth (ingress/egress) " ..
"throughput in bytes",
{"service", "route", "direction", "workspace"})
end
-- AI mode
metrics.ai_llm_requests = prometheus:counter("ai_llm_requests_total",
"AI requests total per ai_provider in Kong",
{"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"})
metrics.ai_llm_cost = prometheus:counter("ai_llm_cost_total",
"AI requests cost per ai_provider/cache in Kong",
{"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"})
metrics.ai_llm_tokens = prometheus:counter("ai_llm_tokens_total",
"AI requests cost per ai_provider/cache in Kong",
{"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "token_type", "workspace"})
metrics.ai_llm_provider_latency = prometheus:histogram("ai_llm_provider_latency_ms",
"LLM response Latency for each AI plugins per ai_provider in Kong",
{"ai_provider", "ai_model", "cache_status", "vector_db", "embeddings_provider", "embeddings_model", "workspace"},
AI_LLM_PROVIDER_LATENCY_BUCKETS)
-- Hybrid mode status
if role == "control_plane" then
metrics.data_plane_last_seen = prometheus:gauge("data_plane_last_seen",
"Last time data plane contacted control plane",
{"node_id", "hostname", "ip"},
prometheus.LOCAL_STORAGE)
metrics.data_plane_config_hash = prometheus:gauge("data_plane_config_hash",
"Config hash numeric value of the data plane",
{"node_id", "hostname", "ip"},
prometheus.LOCAL_STORAGE)
metrics.data_plane_version_compatible = prometheus:gauge("data_plane_version_compatible",
"Version compatible status of the data plane, 0 is incompatible",
{"node_id", "hostname", "ip", "kong_version"},
prometheus.LOCAL_STORAGE)
elseif role == "data_plane" then
local data_plane_cluster_cert_expiry_timestamp = prometheus:gauge(
"data_plane_cluster_cert_expiry_timestamp",
"Unix timestamp of Data Plane's cluster_cert expiry time",
nil,
prometheus.LOCAL_STORAGE)
-- The cluster_cert doesn't change once Kong starts.
-- We set this metrics just once to avoid file read in each scrape.
local f = assert(io.open(kong.configuration.cluster_cert))
local pem = assert(f:read("*a"))
f:close()
local x509 = require("resty.openssl.x509")
local cert = assert(x509.new(pem, "PEM"))
local not_after = assert(cert:get_not_after())
data_plane_cluster_cert_expiry_timestamp:set(not_after)
end
end
local function init_worker()
prometheus:init_worker()
end
local function configure(configs)
IS_PROMETHEUS_ENABLED = false
export_upstream_health_metrics = false
local export_wasm_metrics = false
if configs ~= nil then
IS_PROMETHEUS_ENABLED = true
for i = 1, #configs do
-- `upstream_health_metrics` and `wasm_metrics` are global properties that
-- are disabled by default but will be enabled if any plugin instance has
-- explicitly enabled them
if configs[i].upstream_health_metrics then
export_upstream_health_metrics = true
end
if configs[i].wasm_metrics then
export_wasm_metrics = true
end
-- no need for further iteration since everyhing is enabled
if export_upstream_health_metrics and export_wasm_metrics then
break
end
end
end
wasm.set_enabled(export_wasm_metrics)
end
-- Convert the MD5 hex string to its numeric representation
-- Note the following will be represented as a float instead of int64 since luajit
-- don't like int64. Good news is prometheus uses float instead of int64 as well
local function config_hash_to_number(hash_str)
return tonumber("0x" .. hash_str)
end
-- Since in the prometheus library we create a new table for each diverged label
-- so putting the "more dynamic" label at the end will save us some memory
local labels_table_bandwidth = {0, 0, 0, 0, 0}
local labels_table_status = {0, 0, 0, 0, 0, 0}
local labels_table_latency = {0, 0, 0}
local upstream_target_addr_health_table = {
{ value = 0, labels = { 0, 0, 0, "healthchecks_off", ngx.config.subsystem } },
{ value = 0, labels = { 0, 0, 0, "healthy", ngx.config.subsystem } },
{ value = 0, labels = { 0, 0, 0, "unhealthy", ngx.config.subsystem } },
{ value = 0, labels = { 0, 0, 0, "dns_error", ngx.config.subsystem } },
}
-- ai
local labels_table_ai_llm_status = {0, 0, 0, 0, 0, 0, 0}
local labels_table_ai_llm_tokens = {0, 0, 0, 0, 0, 0, 0, 0}
local function set_healthiness_metrics(table, upstream, target, address, status, metrics_bucket)
for i = 1, #table do
table[i]['labels'][1] = upstream
table[i]['labels'][2] = target
table[i]['labels'][3] = address
table[i]['value'] = (status == table[i]['labels'][4]) and 1 or 0
metrics_bucket:set(table[i]['value'], table[i]['labels'])
end
end
local function log(message, serialized)
if not metrics then
kong.log.err("prometheus: can not log metrics because of an initialization "
.. "error, please make sure that you've declared "
.. "'prometheus_metrics' shared dict in your nginx template")
return
end
local service_name = ""
if message and message.service then
service_name = message.service.name or message.service.host
end
local route_name
if message and message.route then
route_name = message.route.name or message.route.id
else
return
end
local consumer = ""
if http_subsystem then
if message and serialized.consumer ~= nil then
consumer = serialized.consumer
end
else
consumer = nil -- no consumer in stream
end
local workspace = message.workspace_name or ""
if serialized.ingress_size or serialized.egress_size then
labels_table_bandwidth[1] = service_name
labels_table_bandwidth[2] = route_name
labels_table_bandwidth[4] = workspace
labels_table_bandwidth[5] = consumer
local ingress_size = serialized.ingress_size
if ingress_size and ingress_size > 0 then
labels_table_bandwidth[3] = "ingress"
metrics.bandwidth:inc(ingress_size, labels_table_bandwidth)
end
local egress_size = serialized.egress_size
if egress_size and egress_size > 0 then
labels_table_bandwidth[3] = "egress"
metrics.bandwidth:inc(egress_size, labels_table_bandwidth)
end
end
if serialized.status_code then
labels_table_status[1] = service_name
labels_table_status[2] = route_name
labels_table_status[3] = serialized.status_code
if kong.response.get_source() == "service" then
labels_table_status[4] = "service"
else
labels_table_status[4] = "kong"
end
labels_table_status[5] = workspace
labels_table_status[6] = consumer
metrics.status:inc(1, labels_table_status)
end
if serialized.latencies then
labels_table_latency[1] = service_name
labels_table_latency[2] = route_name
labels_table_latency[3] = workspace
if http_subsystem then
local request_latency = serialized.latencies.request
if request_latency and request_latency >= 0 then
metrics.total_latency:observe(request_latency, labels_table_latency)
end
local upstream_latency = serialized.latencies.proxy
if upstream_latency ~= nil and upstream_latency >= 0 then
metrics.upstream_latency:observe(upstream_latency, labels_table_latency)
end
else
local session_latency = serialized.latencies.session
if session_latency and session_latency >= 0 then
metrics.total_latency:observe(session_latency, labels_table_latency)
end
end
local kong_proxy_latency = serialized.latencies.kong
if kong_proxy_latency ~= nil and kong_proxy_latency >= 0 then
metrics.kong_latency:observe(kong_proxy_latency, labels_table_latency)
end
end
if serialized.ai_metrics then
-- prtically, serialized.ai_metrics stores namespaced metrics for at most three use cases
-- proxy: everything going through the proxy path
-- ai-request-transformer:
-- ai-response-transformer: uses LLM to decorade the request/response, but the proxying traffic doesn't go to LLM
for use_case, ai_metrics in pairs(serialized.ai_metrics) do
kong.log.debug("ingesting ai_metrics for use_case: ", use_case)
local cache_status = ai_metrics.cache and ai_metrics.cache.cache_status or ""
local vector_db = ai_metrics.cache and ai_metrics.cache.vector_db or ""
local embeddings_provider = ai_metrics.cache and ai_metrics.cache.embeddings_provider or ""
local embeddings_model = ai_metrics.cache and ai_metrics.cache.embeddings_model or ""
labels_table_ai_llm_status[1] = ai_metrics.meta and ai_metrics.meta.provider_name or ""
labels_table_ai_llm_status[2] = ai_metrics.meta and ai_metrics.meta.request_model or ""
labels_table_ai_llm_status[3] = cache_status
labels_table_ai_llm_status[4] = vector_db
labels_table_ai_llm_status[5] = embeddings_provider
labels_table_ai_llm_status[6] = embeddings_model
labels_table_ai_llm_status[7] = workspace
metrics.ai_llm_requests:inc(1, labels_table_ai_llm_status)
if ai_metrics.usage and ai_metrics.usage.cost and ai_metrics.usage.cost > 0 then
metrics.ai_llm_cost:inc(ai_metrics.usage.cost, labels_table_ai_llm_status)
end
if ai_metrics.meta and ai_metrics.meta.llm_latency and ai_metrics.meta.llm_latency >= 0 then
metrics.ai_llm_provider_latency:observe(ai_metrics.meta.llm_latency, labels_table_ai_llm_status)
end
if ai_metrics.cache and ai_metrics.cache.fetch_latency and ai_metrics.cache.fetch_latency >= 0 then
metrics.ai_cache_fetch_latency:observe(ai_metrics.cache.fetch_latency, labels_table_ai_llm_status)
end
if ai_metrics.cache and ai_metrics.cache.embeddings_latency and ai_metrics.cache.embeddings_latency >= 0 then
metrics.ai_cache_embeddings_latency:observe(ai_metrics.cache.embeddings_latency, labels_table_ai_llm_status)
end
labels_table_ai_llm_tokens[1] = ai_metrics.meta and ai_metrics.meta.provider_name or ""
labels_table_ai_llm_tokens[2] = ai_metrics.meta and ai_metrics.meta.request_model or ""
labels_table_ai_llm_tokens[3] = cache_status
labels_table_ai_llm_tokens[4] = vector_db
labels_table_ai_llm_tokens[5] = embeddings_provider
labels_table_ai_llm_tokens[6] = embeddings_model
labels_table_ai_llm_tokens[8] = workspace
if ai_metrics.usage and ai_metrics.usage.prompt_tokens and ai_metrics.usage.prompt_tokens > 0 then
labels_table_ai_llm_tokens[7] = "prompt_tokens"
metrics.ai_llm_tokens:inc(ai_metrics.usage.prompt_tokens, labels_table_ai_llm_tokens)
end
if ai_metrics.usage and ai_metrics.usage.completion_tokens and ai_metrics.usage.completion_tokens > 0 then
labels_table_ai_llm_tokens[7] = "completion_tokens"
metrics.ai_llm_tokens:inc(ai_metrics.usage.completion_tokens, labels_table_ai_llm_tokens)
end
if ai_metrics.usage and ai_metrics.usage.total_tokens and ai_metrics.usage.total_tokens > 0 then
labels_table_ai_llm_tokens[7] = "total_tokens"
metrics.ai_llm_tokens:inc(ai_metrics.usage.total_tokens, labels_table_ai_llm_tokens)
end
end
end
end
local function metric_data(write_fn)
if not prometheus or not metrics then
kong.log.err("prometheus: plugin is not initialized, please make sure ",
" 'prometheus_metrics' shared dict is present in nginx template")
return kong.response.exit(500, { message = "An unexpected error occurred" })
end
local nginx_statistics = kong.nginx.get_statistics()
metrics.connections:set(nginx_statistics['connections_accepted'], { node_id, kong_subsystem, "accepted" })
metrics.connections:set(nginx_statistics['connections_handled'], { node_id, kong_subsystem, "handled" })
metrics.connections:set(nginx_statistics['total_requests'], { node_id, kong_subsystem, "total" })
metrics.connections:set(nginx_statistics['connections_active'], { node_id, kong_subsystem, "active" })
metrics.connections:set(nginx_statistics['connections_reading'], { node_id, kong_subsystem, "reading" })
metrics.connections:set(nginx_statistics['connections_writing'], { node_id, kong_subsystem, "writing" })
metrics.connections:set(nginx_statistics['connections_waiting'], { node_id, kong_subsystem,"waiting" })
metrics.nginx_requests_total:set(nginx_statistics['total_requests'], { node_id, kong_subsystem })
if http_subsystem then -- only export those metrics once in http as they are shared
metrics.timers:set(ngx_timer_running_count(), {"running"})
metrics.timers:set(ngx_timer_pending_count(), {"pending"})
-- db reachable?
local ok, err = kong.db.connector:connect()
if ok then
metrics.db_reachable:set(1)
else
metrics.db_reachable:set(0)
kong.log.err("prometheus: failed to reach database while processing",
"/metrics endpoint: ", err)
end
if role == "data_plane" then
local cp_reachable = ngx.shared.kong:get("control_plane_connected")
if cp_reachable then
metrics.cp_connected:set(1)
else
metrics.cp_connected:set(0)
end
end
end
local phase = get_phase()
-- only export upstream health metrics in traditional mode and data plane
if role ~= "control_plane" and export_upstream_health_metrics then
-- erase all target/upstream metrics, prevent exposing old metrics
metrics.upstream_target_health:reset()
-- upstream targets accessible?
local upstreams_dict = get_all_upstreams()
for key, upstream_id in pairs(upstreams_dict) do
-- long loop maybe spike proxy request latency, so we
-- need yield to avoid blocking other requests
-- kong.tools.yield.yield(true)
yield(true, phase)
local _, upstream_name = key:match("^([^:]*):(.-)$")
upstream_name = upstream_name and upstream_name or key
-- based on logic from kong.db.dao.targets
local health_info, err = balancer.get_upstream_health(upstream_id)
if err then
kong.log.err("failed getting upstream health: ", err)
end
if health_info then
for target_name, target_info in pairs(health_info) do
if target_info ~= nil and target_info.addresses ~= nil and
#target_info.addresses > 0 then
-- healthchecks_off|healthy|unhealthy
for i = 1, #target_info.addresses do
local address = target_info.addresses[i]
local address_label = address.ip .. ":" .. address.port
local status = lower(address.health)
set_healthiness_metrics(upstream_target_addr_health_table, upstream_name, target_name, address_label, status, metrics.upstream_target_health)
end
else
-- dns_error
set_healthiness_metrics(upstream_target_addr_health_table, upstream_name, target_name, '', 'dns_error', metrics.upstream_target_health)
end
end
end
end
end
-- memory stats
local res = kong.node.get_memory_stats()
for shm_name, value in pairs(res.lua_shared_dicts) do
metrics.memory_stats.shms:set(value.allocated_slabs, { node_id, shm_name, kong_subsystem })
end
for i = 1, #res.workers_lua_vms do
metrics.memory_stats.worker_vms:set(res.workers_lua_vms[i].http_allocated_gc,
{ node_id, res.workers_lua_vms[i].pid, kong_subsystem })
end
-- Hybrid mode status
if role == "control_plane" then
-- Cleanup old metrics
metrics.data_plane_last_seen:reset()
metrics.data_plane_config_hash:reset()
metrics.data_plane_version_compatible:reset()
for data_plane, err in kong.db.clustering_data_planes:each() do
if err then
kong.log.err("failed to list data planes: ", err)
goto next_data_plane
end
local labels = { data_plane.id, data_plane.hostname, data_plane.ip }
metrics.data_plane_last_seen:set(data_plane.last_seen, labels)
metrics.data_plane_config_hash:set(config_hash_to_number(data_plane.config_hash), labels)
labels[4] = data_plane.version
local compatible = 1
if data_plane.sync_status == CLUSTERING_SYNC_STATUS.KONG_VERSION_INCOMPATIBLE
or data_plane.sync_status == CLUSTERING_SYNC_STATUS.PLUGIN_SET_INCOMPATIBLE
or data_plane.sync_status == CLUSTERING_SYNC_STATUS.PLUGIN_VERSION_INCOMPATIBLE then
compatible = 0
end
metrics.data_plane_version_compatible:set(compatible, labels)
::next_data_plane::
end
end
-- notify the function if prometheus plugin is enabled,
-- so that it can avoid exporting unnecessary metrics if not
prometheus:metric_data(write_fn, not IS_PROMETHEUS_ENABLED)
wasm.metrics_data()
end
local function collect()
ngx.header["Content-Type"] = "text/plain; charset=UTF-8"
metric_data()
-- only gather stream metrics if stream_api module is available
-- and user has configured at least one stream listeners
if stream_available and #kong.configuration.stream_listeners > 0 then
local res, err = stream_api.request("prometheus", "")
if err then
kong.log.err("failed to collect stream metrics: ", err)
else
ngx.print(res)
end
end
end
local function get_prometheus()
if not prometheus then
kong.log.err("prometheus: plugin is not initialized, please make sure ",
" 'prometheus_metrics' shared dict is present in nginx template")
end
return prometheus
end
return {
init = init,
init_worker = init_worker,
configure = configure,
log = log,
metric_data = metric_data,
collect = collect,
get_prometheus = get_prometheus,
}