diff options
Diffstat (limited to 'util/openmetrics.lua')
-rw-r--r-- | util/openmetrics.lua | 388 |
1 files changed, 388 insertions, 0 deletions
diff --git a/util/openmetrics.lua b/util/openmetrics.lua new file mode 100644 index 00000000..2fb8b967 --- /dev/null +++ b/util/openmetrics.lua @@ -0,0 +1,388 @@ +--[[ +This module implements a subset of the OpenMetrics Internet Draft version 00. + +URL: https://tools.ietf.org/html/draft-richih-opsawg-openmetrics-00 + +The following metric types are supported: + +- Counter +- Gauge +- Histogram +- Summary + +It is used by util.statsd and util.statistics to provite the OpenMetrics API. + +To understand what this module is about, it is useful to familiarize oneself +with the terms MetricFamily, Metric, LabelSet, Label and MetricPoint as +defined in the I-D linked above. +--]] +-- metric constructor interface: +-- metric_ctor(..., family_name, labels, extra) + +local time = require "util.time".now; +local select = select; +local array = require "util.array"; +local log = require "util.logger".init("util.openmetrics"); +local new_multitable = require "util.multitable".new; +local iter_multitable = require "util.multitable".iter; +local t_concat, t_insert = table.concat, table.insert; +local t_pack, t_unpack = require "util.table".pack, table.unpack or unpack; --luacheck: ignore 113/unpack + +-- BEGIN of Utility: "metric proxy" +-- This allows to wrap a MetricFamily in a proxy which only provides the +-- `with_labels` and `with_partial_label` methods. This allows to pre-set one +-- or more labels on a metric family. This is used in particular via +-- `with_partial_label` by the moduleapi in order to pre-set the `host` label +-- on metrics created in non-global modules. +local metric_proxy_mt = {} +metric_proxy_mt.__index = metric_proxy_mt + +local function new_metric_proxy(metric_family, with_labels_proxy_fun) + return { + _family = metric_family, + with_labels = function(self, ...) + return with_labels_proxy_fun(self._family, ...) + end; + with_partial_label = function(self, label) + return new_metric_proxy(self._family, function(family, ...) + return family:with_labels(label, ...) + end) + end + } +end + +-- END of Utility: "metric proxy" + +-- BEGIN Rendering helper functions (internal) + +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 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 + +-- END Rendering helper functions (internal) + +local function render_histogram_le(v) + if v == 1/0 then + -- I-D-00: 4.1.2.2.1: + -- Exposers MUST produce output for positive infinity as +Inf. + return "+Inf" + end + + return string.format("%.14g", v) +end + +-- BEGIN of generic MetricFamily implementation + +local metric_family_mt = {} +metric_family_mt.__index = metric_family_mt + +local function histogram_metric_ctor(orig_ctor, buckets) + return function(family_name, labels, extra) + return orig_ctor(buckets, family_name, labels, extra) + end +end + +local function new_metric_family(backend, type_, family_name, unit, description, label_keys, extra) + local metric_ctor = assert(backend[type_], "statistics backend does not support "..type_.." metrics families") + local labels = label_keys or {} + local user_labels = #labels + if type_ == "histogram" then + local buckets = extra and extra.buckets + if not buckets then + error("no buckets given for histogram metric") + end + buckets = array(buckets) + buckets:push(1/0) -- must have +inf bucket + + metric_ctor = histogram_metric_ctor(metric_ctor, buckets) + end + + local data + if #labels == 0 then + data = metric_ctor(family_name, nil, extra) + else + data = new_multitable() + end + + local mf = { + family_name = family_name, + data = data, + type_ = type_, + unit = unit, + description = description, + user_labels = user_labels, + label_keys = labels, + extra = extra, + _metric_ctor = metric_ctor, + } + setmetatable(mf, metric_family_mt); + return mf +end + +function metric_family_mt:new_metric(labels) + return self._metric_ctor(self.family_name, labels, self.extra) +end + +function metric_family_mt:clear() + for _, metric in self:iter_metrics() do + metric:reset() + end +end + +function metric_family_mt:with_labels(...) + local count = select('#', ...) + if count ~= self.user_labels then + error("number of labels passed to with_labels does not match number of label keys") + end + if count == 0 then + return self.data + end + local metric = self.data:get(...) + if not metric then + local values = t_pack(...) + metric = self:new_metric(values) + values[values.n+1] = metric + self.data:set(t_unpack(values, 1, values.n+1)) + end + return metric +end + +function metric_family_mt:with_partial_label(label) + return new_metric_proxy(self, function (family, ...) + return family:with_labels(label, ...) + end) +end + +function metric_family_mt:iter_metrics() + if #self.label_keys == 0 then + local done = false + return function() + if done then + return nil + end + done = true + return {}, self.data + end + end + local searchkeys = {}; + local nlabels = #self.label_keys + for i=1,nlabels do + searchkeys[i] = nil; + end + local it, state = iter_multitable(self.data, t_unpack(searchkeys, 1, nlabels)) + return function(_s) + local label_values = t_pack(it(_s)) + if label_values.n == 0 then + return nil, nil + end + local metric = label_values[label_values.n] + label_values[label_values.n] = nil + label_values.n = label_values.n - 1 + return label_values, metric + end, state +end + +-- END of generic MetricFamily implementation + +-- BEGIN of MetricRegistry implementation + + +-- Helper to test whether two metrics are "equal". +local function equal_metric_family(mf1, mf2) + if mf1.type_ ~= mf2.type_ then + return false + end + if #mf1.label_keys ~= #mf2.label_keys then + return false + end + -- Ignoring unit here because in general it'll be part of the name anyway + -- So either the unit was moved into/out of the name (which is a valid) + -- thing to do on an upgrade or we would expect not to see any conflicts + -- anyway. + --[[ + if mf1.unit ~= mf2.unit then + return false + end + ]] + for i, key in ipairs(mf1.label_keys) do + if key ~= mf2.label_keys[i] then + return false + end + end + return true +end + +-- If the unit is not empty, add it to the full name as per the I-D spec. +local function compose_name(name, unit) + local full_name = name + if unit and unit ~= "" then + full_name = full_name .. "_" .. unit + end + -- TODO: prohibit certain suffixes used by metrics if where they may cause + -- conflicts + return full_name +end + +local metric_registry_mt = {} +metric_registry_mt.__index = metric_registry_mt + +local function new_metric_registry(backend) + local reg = { + families = {}, + backend = backend, + } + setmetatable(reg, metric_registry_mt) + return reg +end + +function metric_registry_mt:register_metric_family(name, metric_family) + local existing = self.families[name]; + if existing then + if not equal_metric_family(metric_family, existing) then + -- We could either be strict about this, or replace the + -- existing metric family with the new one. + -- Being strict is nice to avoid programming errors / + -- conflicts, but causes issues when a new version of a module + -- is loaded. + -- + -- We will thus assume that the new metric is the correct one; + -- That is probably OK because unless you're reaching down into + -- the util.openmetrics or core.statsmanager API, your metric + -- name is going to be scoped to `prosody_mod_$modulename` + -- anyway and the damage is thus controlled. + -- + -- To make debugging such issues easier, we still log. + log("debug", "replacing incompatible existing metric family %s", name) + -- Below is the code to be strict. + --error("conflicting declarations for metric family "..name) + else + return existing + end + end + self.families[name] = metric_family + return metric_family +end + +function metric_registry_mt:gauge(name, unit, description, labels, extra) + name = compose_name(name, unit) + local mf = new_metric_family(self.backend, "gauge", name, unit, description, labels, extra) + mf = self:register_metric_family(name, mf) + return mf +end + +function metric_registry_mt:counter(name, unit, description, labels, extra) + name = compose_name(name, unit) + local mf = new_metric_family(self.backend, "counter", name, unit, description, labels, extra) + mf = self:register_metric_family(name, mf) + return mf +end + +function metric_registry_mt:histogram(name, unit, description, labels, extra) + name = compose_name(name, unit) + local mf = new_metric_family(self.backend, "histogram", name, unit, description, labels, extra) + mf = self:register_metric_family(name, mf) + return mf +end + +function metric_registry_mt:summary(name, unit, description, labels, extra) + name = compose_name(name, unit) + local mf = new_metric_family(self.backend, "summary", name, unit, description, labels, extra) + mf = self:register_metric_family(name, mf) + return mf +end + +function metric_registry_mt:get_metric_families() + return self.families +end + +function metric_registry_mt:render() + local answer = {}; + for metric_family_name, metric_family in pairs(self: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 + +-- END of MetricRegistry implementation + +-- BEGIN of general helpers for implementing high-level APIs on top of OpenMetrics + +local function timed(metric) + local t0 = time() + local submitter = assert(metric.sample or metric.set, "metric type cannot be used with timed()") + return function() + local t1 = time() + submitter(metric, t1-t0) + end +end + +-- END of general helpers + +return { + new_metric_proxy = new_metric_proxy; + new_metric_registry = new_metric_registry; + render_histogram_le = render_histogram_le; + timed = timed; +} |