local cjson = require "cjson.safe" local system = require "kong.tools.system" local rand = require "kong.tools.rand" local constants = require "kong.constants" local counter = require "resty.counter" local knode = (kong and kong.node) and kong.node or require "kong.pdk.node".new() local ai_plugin_o11y = require "kong.llm.plugin.observability" local kong_dict = ngx.shared.kong local ngx = ngx local tcp_sock = ngx.socket.tcp local timer_at = ngx.timer.at local ngx_log = ngx.log local var = ngx.var local subsystem = ngx.config.subsystem local concat = table.concat local tostring = tostring local lower = string.lower local pairs = pairs local error = error local type = type local WARN = ngx.WARN local DEBUG = ngx.DEBUG local sub = string.sub local PING_INTERVAL = 3600 local PING_KEY = "events:reports" local REQUEST_COUNT_KEY = "events:requests" local HTTP_REQUEST_COUNT_KEY = "events:requests:http" local HTTPS_REQUEST_COUNT_KEY = "events:requests:https" local H2C_REQUEST_COUNT_KEY = "events:requests:h2c" local H2_REQUEST_COUNT_KEY = "events:requests:h2" local GRPC_REQUEST_COUNT_KEY = "events:requests:grpc" local GRPCS_REQUEST_COUNT_KEY = "events:requests:grpcs" local WS_REQUEST_COUNT_KEY = "events:requests:ws" local WSS_REQUEST_COUNT_KEY = "events:requests:wss" local STREAM_COUNT_KEY = "events:streams" local TCP_STREAM_COUNT_KEY = "events:streams:tcp" local TLS_STREAM_COUNT_KEY = "events:streams:tls" local UDP_STREAM_COUNT_KEY = "events:streams:udp" local KM_VISIT_COUNT_KEY = "events:km:visit" local GO_PLUGINS_REQUEST_COUNT_KEY = "events:requests:go_plugins" local WASM_REQUEST_COUNT_KEY = "events:requests:wasm" local AI_RESPONSE_TOKENS_COUNT_KEY = "events:ai:response_tokens" local AI_PROMPT_TOKENS_COUNT_KEY = "events:ai:prompt_tokens" local AI_REQUEST_COUNT_KEY = "events:ai:requests" local ROUTE_CACHE_HITS_KEY = "route_cache_hits" local STEAM_ROUTE_CACHE_HITS_KEY_POS = STREAM_COUNT_KEY .. ":" .. ROUTE_CACHE_HITS_KEY .. ":pos" local STEAM_ROUTE_CACHE_HITS_KEY_NEG = STREAM_COUNT_KEY .. ":" .. ROUTE_CACHE_HITS_KEY .. ":neg" local REQUEST_ROUTE_CACHE_HITS_KEY_POS = REQUEST_COUNT_KEY .. ":" .. ROUTE_CACHE_HITS_KEY .. ":pos" local REQUEST_ROUTE_CACHE_HITS_KEY_NEG = REQUEST_COUNT_KEY .. ":" .. ROUTE_CACHE_HITS_KEY .. ":neg" local get_header if subsystem == "http" then get_header = require("kong.tools.http").get_header end local _buffer = {} local _ping_infos = {} local _enabled = false local _unique_str = rand.random_string() local _buffer_immutable_idx local _ssl_session local _ssl_verify = false -- the resty.counter instance, will be initialized in `init_worker` local report_counter = nil do -- initialize immutable buffer data (the same for each report) local meta = require "kong.meta" local system_infos = system.get_system_infos() system_infos.hostname = system_infos.hostname or knode.get_hostname() -- <14>: syslog facility code 'log alert' _buffer[#_buffer + 1] = "<14>version=" .. meta._VERSION for k, v in pairs(system_infos) do _buffer[#_buffer + 1] = k .. "=" .. v end _buffer_immutable_idx = #_buffer -- max idx for immutable slots end local function log(lvl, ...) ngx_log(lvl, "[reports] ", ...) end local function serialize_report_value(v) if type(v) == "function" then v = v() end if type(v) == "table" then local json, err = cjson.encode(v) if err then log(WARN, "could not JSON encode given table entity: ", err) end v = json end return v ~= nil and tostring(v) or nil end -- TCP logger local function send_report(signal_type, t, host, port) if not _enabled then return nil, "disabled" elseif type(signal_type) ~= "string" then return error("signal_type (arg #1) must be a string", 2) end t = t or {} host = host or constants.REPORTS.ADDRESS port = port or constants.REPORTS.STATS_TLS_PORT -- add signal type to data t.signal = signal_type -- insert given entity in mutable part of buffer local mutable_idx = _buffer_immutable_idx for k, v in pairs(t) do if k == "unique_id" or k == "cluster_id" or (k ~= "created_at" and sub(k, -2) ~= "id") then v = serialize_report_value(v) if v ~= nil then mutable_idx = mutable_idx + 1 _buffer[mutable_idx] = k .. "=" .. v end end end local sock = tcp_sock() sock:settimeouts(30000, 30000, 30000) -- errors are not logged to avoid false positives for users -- who run Kong in an air-gapped environments local ok, err ok, err = sock:connect(host, port) if not ok then sock:close() return nil, err end ok, err = sock:sslhandshake(_ssl_session, nil, _ssl_verify) if not ok then log(DEBUG, "failed to complete SSL handshake for reports: ", err) sock:close() return nil, "failed to complete SSL handshake for reports: " .. err end _ssl_session = ok -- send return nil plus err msg on failure local bytes, err = sock:send(concat(_buffer, ";", 1, mutable_idx) .. "\n") if bytes then local ok, err = sock:setkeepalive() if not ok then log(DEBUG, "failed to keepalive to ", host, ":", tostring(port), ": ", err) sock:close() end end return bytes, err end -- ping timer handler -- Hold a lock for the whole interval (exptime) to prevent multiple -- worker processes from sending the test request simultaneously. -- Other workers do not need to wait until this lock is released, -- and can ignore the event, knowing another worker is handling it. -- We subtract 1ms to the exp time to prevent a race condition -- with the next timer event. local function get_lock(key, exptime) local ok, err = kong_dict:safe_add(key, true, exptime - 0.001) if not ok and err ~= "exists" then log(WARN, "could not get lock from 'kong' shm: ", err) end return ok end local function create_timer(...) local ok, err = timer_at(...) if not ok then log(WARN, "could not create ping timer: ", err) end end -- @param interval exposed for unit test only local function create_counter(interval) local err -- create a counter instance which syncs to `kong` shdict every 10 minutes report_counter, err = counter.new('kong', interval or 600) return err end local function get_counter(key) local count, err = report_counter:get(key) if err then log(WARN, "could not get ", key, " from 'kong' shm: ", err) end return count or 0 end local function reset_counter(key, amount) local ok, err = report_counter:reset(key, amount) if not ok then log(WARN, "could not reset ", key, " in 'kong' shm: ", err) end end local function incr_counter(key, hit) if not hit then hit = 1 end local ok, err = report_counter:incr(key, hit) if not ok then log(WARN, "could not increment ", key, " in 'kong' shm: ", err) end end -- returns a string indicating the "kind" of the current request/stream: -- "http", "https", "h2c", "h2", "grpc", "grpcs", "ws", "wss", "tcp", "tls", "udp" -- or nil + error message if the suffix could not be determined local get_current_suffix if subsystem == "http" then function get_current_suffix(ctx) local scheme = var.scheme local proxy_mode = var.kong_proxy_mode if scheme == "http" or scheme == "https" then if proxy_mode == "http" or proxy_mode == "unbuffered" then local http_upgrade = get_header("upgrade", ctx) if http_upgrade and lower(http_upgrade) == "websocket" then if scheme == "http" then return "ws" end return "wss" end if ngx.req.http_version() == 2 then if scheme == "http" then return "h2c" end return "h2" end return scheme -- http/https end if proxy_mode == "grpc" then if scheme == "http" then return "grpc" end if scheme == "https" then return "grpcs" end end end if ctx.KONG_UNEXPECTED then return nil end -- 400 case is for invalid requests, eg: if a client sends a HTTP -- request to a HTTPS port, it does not initialized any Nginx variables if proxy_mode == "" and kong.response.get_status() == 400 then return nil end log(WARN, "could not determine log suffix (scheme=", tostring(scheme), ", proxy_mode=", tostring(proxy_mode), ")") end else -- subsystem == "stream" function get_current_suffix(ctx) if var.ssl_protocol then return "tls" end return lower(var.protocol) end end local function send_ping(host, port) _ping_infos.unique_id = _unique_str if not _ping_infos.cluster_id then _ping_infos.cluster_id = kong.cluster.get_id() end if subsystem == "stream" then _ping_infos.streams = get_counter(STREAM_COUNT_KEY) _ping_infos.tcp_streams = get_counter(TCP_STREAM_COUNT_KEY) _ping_infos.udp_streams = get_counter(UDP_STREAM_COUNT_KEY) _ping_infos.tls_streams = get_counter(TLS_STREAM_COUNT_KEY) _ping_infos.go_plugin_reqs = get_counter(GO_PLUGINS_REQUEST_COUNT_KEY) _ping_infos.wasm_reqs = get_counter(WASM_REQUEST_COUNT_KEY) _ping_infos.stream_route_cache_hit_pos = get_counter(STEAM_ROUTE_CACHE_HITS_KEY_POS) _ping_infos.stream_route_cache_hit_neg = get_counter(STEAM_ROUTE_CACHE_HITS_KEY_NEG) _ping_infos.ai_response_tokens = get_counter(AI_RESPONSE_TOKENS_COUNT_KEY) _ping_infos.ai_prompt_tokens = get_counter(AI_PROMPT_TOKENS_COUNT_KEY) _ping_infos.ai_reqs = get_counter(AI_REQUEST_COUNT_KEY) send_report("ping", _ping_infos, host, port) reset_counter(STREAM_COUNT_KEY, _ping_infos.streams) reset_counter(TCP_STREAM_COUNT_KEY, _ping_infos.tcp_streams) reset_counter(UDP_STREAM_COUNT_KEY, _ping_infos.udp_streams) reset_counter(TLS_STREAM_COUNT_KEY, _ping_infos.tls_streams) reset_counter(GO_PLUGINS_REQUEST_COUNT_KEY, _ping_infos.go_plugin_reqs) reset_counter(WASM_REQUEST_COUNT_KEY, _ping_infos.wasm_reqs) reset_counter(STEAM_ROUTE_CACHE_HITS_KEY_POS, _ping_infos.stream_route_cache_hit_pos) reset_counter(STEAM_ROUTE_CACHE_HITS_KEY_NEG, _ping_infos.stream_route_cache_hit_neg) reset_counter(AI_RESPONSE_TOKENS_COUNT_KEY, _ping_infos.ai_response_tokens) reset_counter(AI_PROMPT_TOKENS_COUNT_KEY, _ping_infos.ai_prompt_tokens) reset_counter(AI_REQUEST_COUNT_KEY, _ping_infos.ai_reqs) return end _ping_infos.requests = get_counter(REQUEST_COUNT_KEY) _ping_infos.http_reqs = get_counter(HTTP_REQUEST_COUNT_KEY) _ping_infos.https_reqs = get_counter(HTTPS_REQUEST_COUNT_KEY) _ping_infos.h2c_reqs = get_counter(H2C_REQUEST_COUNT_KEY) _ping_infos.h2_reqs = get_counter(H2_REQUEST_COUNT_KEY) _ping_infos.grpc_reqs = get_counter(GRPC_REQUEST_COUNT_KEY) _ping_infos.grpcs_reqs = get_counter(GRPCS_REQUEST_COUNT_KEY) _ping_infos.ws_reqs = get_counter(WS_REQUEST_COUNT_KEY) _ping_infos.wss_reqs = get_counter(WSS_REQUEST_COUNT_KEY) _ping_infos.km_visits = get_counter(KM_VISIT_COUNT_KEY) _ping_infos.go_plugin_reqs = get_counter(GO_PLUGINS_REQUEST_COUNT_KEY) _ping_infos.wasm_reqs = get_counter(WASM_REQUEST_COUNT_KEY) _ping_infos.ai_response_tokens = get_counter(AI_RESPONSE_TOKENS_COUNT_KEY) _ping_infos.ai_prompt_tokens = get_counter(AI_PROMPT_TOKENS_COUNT_KEY) _ping_infos.ai_reqs = get_counter(AI_REQUEST_COUNT_KEY) _ping_infos.request_route_cache_hit_pos = get_counter(REQUEST_ROUTE_CACHE_HITS_KEY_POS) _ping_infos.request_route_cache_hit_neg = get_counter(REQUEST_ROUTE_CACHE_HITS_KEY_NEG) send_report("ping", _ping_infos, host, port) reset_counter(REQUEST_COUNT_KEY, _ping_infos.requests) reset_counter(HTTP_REQUEST_COUNT_KEY, _ping_infos.http_reqs) reset_counter(HTTPS_REQUEST_COUNT_KEY, _ping_infos.https_reqs) reset_counter(H2C_REQUEST_COUNT_KEY, _ping_infos.h2c_reqs) reset_counter(H2_REQUEST_COUNT_KEY, _ping_infos.h2_reqs) reset_counter(GRPC_REQUEST_COUNT_KEY, _ping_infos.grpc_reqs) reset_counter(GRPCS_REQUEST_COUNT_KEY, _ping_infos.grpcs_reqs) reset_counter(WS_REQUEST_COUNT_KEY, _ping_infos.ws_reqs) reset_counter(WSS_REQUEST_COUNT_KEY, _ping_infos.wss_reqs) reset_counter(KM_VISIT_COUNT_KEY, _ping_infos.km_visits) reset_counter(GO_PLUGINS_REQUEST_COUNT_KEY, _ping_infos.go_plugin_reqs) reset_counter(WASM_REQUEST_COUNT_KEY, _ping_infos.wasm_reqs) reset_counter(REQUEST_ROUTE_CACHE_HITS_KEY_POS, _ping_infos.request_route_cache_hit_pos) reset_counter(REQUEST_ROUTE_CACHE_HITS_KEY_NEG, _ping_infos.request_route_cache_hit_neg) reset_counter(AI_RESPONSE_TOKENS_COUNT_KEY, _ping_infos.ai_response_tokens) reset_counter(AI_PROMPT_TOKENS_COUNT_KEY, _ping_infos.ai_prompt_tokens) reset_counter(AI_REQUEST_COUNT_KEY, _ping_infos.ai_reqs) end local function ping_handler(premature) if premature then return end -- all workers need to register a recurring timer, in case one of them -- crashes. Hence, this must be called before the `get_lock()` call. create_timer(PING_INTERVAL, ping_handler) if not get_lock(PING_KEY, PING_INTERVAL) then return end send_ping() end local function add_ping_value(k, v) _ping_infos[k] = v end local function add_immutable_value(k, v) v = serialize_report_value(v) if v ~= nil then _buffer_immutable_idx = _buffer_immutable_idx + 1 _buffer[_buffer_immutable_idx] = k .. "=" .. v end end local function configure_ping(kong_conf) if type(kong_conf) ~= "table" then error("kong_config must be a table", 2) end add_immutable_value("database", kong_conf.database) add_immutable_value("role", kong_conf.role) add_immutable_value("kic", kong_conf.kic) add_immutable_value("_admin", #kong_conf.admin_listeners > 0 and 1 or 0) add_immutable_value("_admin_gui", #kong_conf.admin_gui_listeners > 0 and 1 or 0) add_immutable_value("_proxy", #kong_conf.proxy_listeners > 0 and 1 or 0) add_immutable_value("_stream", #kong_conf.stream_listeners > 0 and 1 or 0) add_immutable_value("new_dns_client", kong_conf.new_dns_client) end local retrieve_redis_version do local _retrieved_redis_version = false retrieve_redis_version = function(red) if not _enabled or _retrieved_redis_version then return end -- we will run this branch for each worker's first hit, but never -- again. Hopefully someday Redis will be made a first class citizen -- in Kong and its integration can be tied deeper into the core, -- avoiding such "hacks". _retrieved_redis_version = true local redis_version -- This logic should work for Redis >= 2.4. local res, err = red:info("server") if type(res) ~= "string" then -- could be nil or ngx.null ngx_log(WARN, "failed to retrieve Redis version: ", err) else -- retrieve first 2 digits only redis_version = res:match("redis_version:(%d+%.%d+).-\r\n") end add_ping_value("redis_version", redis_version or "unknown") end end return { init = function(kong_conf) _enabled = kong_conf.anonymous_reports or false configure_ping(kong_conf) end, -- plugin handler init_worker = function() if not _enabled then return end create_timer(PING_INTERVAL, ping_handler) local err = create_counter() if err then error(err) end end, add_immutable_value = add_immutable_value, configure_ping = configure_ping, add_ping_value = add_ping_value, get_ping_value = function(k) return _ping_infos[k] end, send_ping = send_ping, log = function(ctx) if not _enabled then return end local count_key = subsystem == "stream" and STREAM_COUNT_KEY or REQUEST_COUNT_KEY incr_counter(count_key) if ctx.ran_go_plugin then incr_counter(GO_PLUGINS_REQUEST_COUNT_KEY) end if ctx.ran_wasm then incr_counter(WASM_REQUEST_COUNT_KEY) end local llm_prompt_tokens_count = ai_plugin_o11y.metrics_get("llm_prompt_tokens_count") if llm_prompt_tokens_count then incr_counter(AI_REQUEST_COUNT_KEY) incr_counter(AI_PROMPT_TOKENS_COUNT_KEY, llm_prompt_tokens_count) end local llm_response_tokens_count = ai_plugin_o11y.metrics_get("llm_completion_tokens_count") if llm_response_tokens_count then incr_counter(AI_RESPONSE_TOKENS_COUNT_KEY, llm_response_tokens_count) end local suffix = get_current_suffix(ctx) if suffix then incr_counter(count_key .. ":" .. suffix) end local route_match_cached = ctx.route_match_cached if route_match_cached then incr_counter(count_key .. ":" .. ROUTE_CACHE_HITS_KEY .. ":" .. route_match_cached) end end, admin_gui_log = function(ctx) if not _enabled then return end incr_counter(KM_VISIT_COUNT_KEY) end, -- custom methods toggle = function(enable) _enabled = enable end, send = send_report, retrieve_redis_version = retrieve_redis_version, -- exposed for unit test _create_counter = create_counter, -- exposed for integration test _sync_counter = function() report_counter:sync() end, }