view mod_prometheus/mod_prometheus.lua @ 5366:db4c66a1d24b

mod_http_oauth2: Fill in some client metadata defaults Explicit > Implicit Maybe we should actually use these for something as well? :) It's is somewhat an open question of how strictly we should enforce things in the client metadata given that it is somewhat extensible. Especially some of these enum fields which have corresponding IANA registries.
author Kim Alvefur <zash@zash.se>
date Tue, 25 Apr 2023 18:09:08 +0200
parents c406e4bf7ee5
children
line wrap: on
line source

-- Log statistics to Prometheus
--
-- Copyright (C) 2014 Daurnimator
-- Copyright (C) 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
-- Copyright (C) 2021 Jonas Schäfer <jonas@zombofant.net>
--
-- This module is MIT/X11 licensed.

module:set_global();

local tostring = tostring;
local t_insert = table.insert;
local t_concat = table.concat;
local socket = require "socket";
local statsman = require "core.statsmanager";
local get_stats = statsman.get_stats;
local get_metric_registry = statsman.get_metric_registry;
local collect = statsman.collect;

local function escape(text)
	return text:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n");
end

local function escape_name(name)
	return name:gsub("/", "__"):gsub("[^A-Za-z0-9_]", "_"):gsub("^[^A-Za-z_]", "_%1");
end

local function get_timestamp()
	-- Using LuaSocket for that because os.time() only has second precision.
	return math.floor(socket.gettime() * 1000);
end

local function repr_help(metric, docstring)
	docstring = docstring:gsub("\\", "\\\\"):gsub("\n", "\\n");
	return "# HELP "..escape_name(metric).." "..docstring.."\n";
end

local function repr_unit(metric, unit)
	if not unit then
		unit = ""
	else
		unit = unit:gsub("\\", "\\\\"):gsub("\n", "\\n");
	end
	return "# UNIT "..escape_name(metric).." "..unit.."\n";
end

-- local allowed_types = { counter = true, gauge = true, histogram = true, summary = true, untyped = true };
-- local allowed_types = { "counter", "gauge", "histogram", "summary", "untyped" };
local function repr_type(metric, type_)
	-- if not allowed_types:contains(type_) then
	-- 	return;
	-- end
	return "# TYPE "..escape_name(metric).." "..type_.."\n";
end

local function repr_label(key, value)
	return key.."=\""..escape(value).."\"";
end

local function repr_labels(labelkeys, labelvalues, extra_labels)
	local values = {}
	if labelkeys then
		for i, key in ipairs(labelkeys) do
			local value = labelvalues[i]
			t_insert(values, repr_label(escape_name(key), escape(value)));
		end
	end
	if extra_labels then
		for key, value in pairs(extra_labels) do
			t_insert(values, repr_label(escape_name(key), escape(value)));
		end
	end
	if #values == 0 then
		return "";
	end
	return "{"..t_concat(values, ",").."}";
end

local function repr_sample(metric, labelkeys, labelvalues, extra_labels, value)
	return escape_name(metric)..repr_labels(labelkeys, labelvalues, extra_labels).." "..string.format("%.17g", value).."\n";
end

local get_metrics;
if statsman.get_metric_registry then
	module:log("debug", "detected OpenMetrics statsmanager")
	-- Prosody 0.12+ with OpenMetrics
	function get_metrics(event)
		local response = event.response;
		response.headers.content_type = "application/openmetrics-text; version=0.0.4";

		if collect then
			-- Ensure to get up-to-date samples when running in manual mode
			collect()
		end

		local registry = get_metric_registry()
		if registry == nil then
			response.headers.content_type = "text/plain; charset=utf-8"
			response.status_code = 404
			return "No statistics provider configured\n"
		end
		local answer = {};
		for metric_family_name, metric_family in pairs(registry:get_metric_families()) do
			t_insert(answer, repr_help(metric_family_name, metric_family.description))
			t_insert(answer, repr_unit(metric_family_name, metric_family.unit))
			t_insert(answer, repr_type(metric_family_name, metric_family.type_))
			for labelset, metric in metric_family:iter_metrics() do
				for suffix, extra_labels, value in metric:iter_samples() do
					t_insert(answer, repr_sample(metric_family_name..suffix, metric_family.label_keys, labelset, extra_labels, value))
				end
			end
		end
		t_insert(answer, "# EOF\n")
		return t_concat(answer, "");
	end
else
	module:log("debug", "detected pre-OpenMetrics statsmanager")
	-- Pre-OpenMetrics

	local allowed_extras = { min = true, max = true, average = true };
	local function insert_extras(data, key, name, timestamp, extra)
		if not extra then
			return false;
		end
		local has_extra = false;
		for extra_name in pairs(allowed_extras) do
			if extra[extra_name] then
				local field = {
					value = extra[extra_name],
					labels = {
						["type"] = name,
						field = extra_name,
					},
					typ = "gauge";
					timestamp = timestamp,
				};
				t_insert(data[key], field);
				has_extra = true;
			end
		end
		return has_extra;
	end

	local function parse_stats()
		local timestamp = tostring(get_timestamp());
		local data = {};
		local stats, changed_only, extras = get_stats();
		for stat, value in pairs(stats) do
			-- module:log("debug", "changed_stats[%q] = %s", stat, tostring(value));
			local extra = extras[stat];
			local host, sect, name, typ = stat:match("^/([^/]+)/([^/]+)/(.+):(%a+)$");
			if host == nil then
				sect, name, typ = stat:match("^([^.]+)%.(.+):(%a+)$");
			elseif host == "*" then
				host = nil;
			end
			if sect:find("^mod_measure_.") then
				sect = sect:sub(13);
			elseif sect:find("^mod_statistics_.") then
				sect = sect:sub(16);
			end

			local key = escape_name("prosody_"..sect);
			local field = {
				value = value,
				labels = { ["type"] = name},
				-- TODO: Use the other types where it makes sense.
				typ = (typ == "rate" and "counter" or "gauge"),
				timestamp = timestamp,
			};
			if host then
				field.labels.host = host;
			end
			if data[key] == nil then
				data[key] = {};
			end
			if not insert_extras(data, key, name, timestamp, extra) then
				t_insert(data[key], field);
			end
		end
		return data;
	end

	function get_metrics(event)
		local response = event.response;
		response.headers.content_type = "text/plain; version=0.0.4";
		if statsman.collect then
			statsman.collect()
		end

		local answer = {};
		for key, fields in pairs(parse_stats()) do
			t_insert(answer, repr_help(key, ""));
			t_insert(answer, repr_type(key, fields[1].typ));
			for _, field in pairs(fields) do
				t_insert(answer, repr_sample(key, nil, nil, field.labels, field.value, field.timestamp));
			end
		end
		return t_concat(answer, "");
	end
end

function module.add_host(module)
	module:depends "http";
	module:provides("http", {
		default_path = "metrics";
		route = {
			GET = get_metrics;
		};
	});
end